# 持仓计价方案设计文档 ## 一、背景与需求 ### 1.1 产品定位 投资记账软件,用于记录用户分散在不同市场、不同券商下的所有资产,统计总市值、投资收益率、年化收益率等。 ### 1.2 新建持仓设计决策 #### 1.2.1 设计方案选择 **问题:** 新建持仓时,应该先输入并联想,还是先选择资产类型再搜索? **决策:** 采用**方案A:搜索优先 + 智能切换**的设计。 **方案对比:** | 方案 | 优点 | 缺点 | 适用场景 | |------|------|------|----------| | **方案A:搜索优先 + 智能切换** | ✅ 灵活,支持多种创建方式
✅ 容错性强,搜索失败可切换
✅ 用户体验流畅 | ⚠️ 实现复杂度稍高 | **推荐采用** | | 方案B:资产类型优先 + 条件搜索 | ✅ 逻辑清晰
✅ 搜索范围精准 | ⚠️ 不够灵活
⚠️ 必须先选择类型 | 备选方案 | #### 1.2.2 方案A:搜索优先 + 智能切换(最终采用) **核心设计思路:** 1. **搜索框优先,但不强制** - 搜索框始终显示在顶部 - 用户可以选择搜索或直接手动输入 - 搜索失败时,可以无缝切换到手动输入 2. **智能联动** - 选择资产类型后,搜索框自动过滤该类型 - 搜索时,优先显示匹配的资产类型 - 字段根据选择动态显示/隐藏 3. **渐进式披露** - 第一步:搜索框 + 资产类型选择 - 第二步:根据选择显示对应字段 - 第三步:填写价格和数量 **弹窗布局(从上到下):** ``` ┌─────────────────────────────────────────┐ │ 搜索框(始终显示,支持全局搜索) │ │ [🔍 输入代码/名称搜索...] │ │ ↓ 联想结果列表(最多10条) │ ├─────────────────────────────────────────┤ │ 资产类型选择(Tab切换,可选) │ │ [股票] [基金] [债券] [现金] [其他] │ ├─────────────────────────────────────────┤ │ 根据资产类型显示字段: │ │ - 股票/基金/债券:市场、券商、代码、名称 │ │ - 现金:券商 │ ├─────────────────────────────────────────┤ │ 价格和数量: │ │ - 成本价(人民币)、数量、最新价(可选) │ ├─────────────────────────────────────────┤ │ 其他选项: │ │ - 货币类型、是否自动更新价格 │ └─────────────────────────────────────────┘ ``` **三种创建方式:** **方式1:快速创建(通过搜索)** - 用户在搜索框输入资产代码或名称 - 系统联想出结果,用户选择 - 系统自动填充:资产类型、市场、资产代码、资产名称 - 用户只需填写:券商、成本价、数量、最新价 **方式2:智能创建(先选类型再搜索)** - 用户先选择资产类型(如:股票) - 搜索框自动过滤,只搜索该类型 - 用户输入代码或名称搜索 - 选择结果后自动填充相关信息 **方式3:手动创建(不通过搜索)** - 用户选择资产类型 - 跳过搜索,直接手动填写所有字段 - 或搜索无结果时,自动切换到手动输入模式 **设计原则:** - ✅ **灵活性**:三种方式默认都支持,用户可以根据习惯选择 - ✅ **智能联动**:搜索框根据资产类型自动过滤 - ✅ **容错性**:搜索失败可以无缝切换到手动输入 - ✅ **渐进式披露**:根据用户选择逐步显示字段,不会一次性显示所有字段 - ✅ **自动填充**:通过搜索选择后,相关字段自动填充且禁用,防止误修改 - ✅ **可重置**:用户可以点击"重新选择"清空搜索结果,恢复手动填写 **关键交互细节:** 1. **搜索框智能过滤** - 用户选择"股票" → 搜索框自动过滤,只搜索股票 - 用户选择"基金" → 搜索框自动过滤,只搜索基金 - 用户未选择 → 搜索框全局搜索所有类型(股票、基金、债券) 2. **搜索失败处理** - 搜索无结果 → 显示"未找到匹配的资产,点击手动输入" - 点击后 → 隐藏搜索框(可选),显示完整表单 - 用户可以继续手动填写所有信息 3. **字段联动** - 通过搜索选择 → 自动填充:资产类型、市场、资产代码、资产名称(禁用状态) - 手动输入 → 所有字段可编辑 - 切换资产类型 → 清空相关字段,重新显示对应字段 4. **搜索框状态** - 未选择:显示占位符"输入代码/名称搜索..." - 已选择:显示选中的资产名称,右侧显示"重新选择"按钮 - 点击"重新选择" → 清空选择,恢复搜索状态 ### 1.2 核心需求 - 用户记录持仓数据(成本价、最新价、持股数量) - 每日收盘后自动更新持仓股票的最新价格 - 计算当日资产总值、净值 - 呈现所有持仓的总市值变化情况、各持仓占比 - 形成图表,方便用户统计资产总市值、投资收益率、年化收益率 - **最终总资产、总成本和各持仓市值占比需要使用人民币进行计价** ### 1.3 用户操作场景 - 用户每次变更持仓时,只需要更新:**成本价、最新价、持股数量** 三个数值 - 用户可能在不同日期多次买入/卖出同一只股票 - 用户可能持有A股、港股、美股等不同市场的资产 ### 1.4 核心问题 如何统一处理不同市场的货币计价问题,确保最终展示和计算统一为人民币? --- ## 二、方案对比 ### 2.1 方案A:统一人民币存储方案(推荐采用) #### 2.1.1 核心思路 - **用户输入**:所有价格统一使用人民币计价 - **数据存储**:数据库中所有价格字段统一存储为人民币 - **系统处理**:所有计算和展示统一为人民币 - **无需汇率**:完全避免汇率问题 #### 2.1.2 数据存储设计 **数据库字段:** ```sql -- positions 表 cost_price DECIMAL(18, 4) -- 成本价(人民币) current_price DECIMAL(18, 4) -- 最新价(人民币) previous_price DECIMAL(18, 4) -- 上一次价格(人民币,用于涨跌显示) shares DECIMAL(18, 4) -- 持股数量 currency VARCHAR(10) -- 货币类型(仅用于标识市场,不影响计算) ``` **字段说明:** - `cost_price`:成本价,统一为人民币 - `current_price`:最新价,统一为人民币 - `currency`:仅用于标识市场(CNY/HKD/USD),不影响计算和展示 #### 2.1.3 用户操作流程 **创建持仓:** 1. 用户输入成本价(人民币) 2. 用户输入最新价(人民币) 3. 用户输入持股数量 4. 用户选择市场(CNY/HKD/USD,仅标识) **更新持仓:** 1. 用户修改成本价(人民币) 2. 用户修改最新价(人民币) 3. 用户修改持股数量 **价格更新:** - **手动更新**:用户直接在界面上更新人民币价格 - **自动更新**:系统获取原始货币价格 → 转换为人民币 → 更新到数据库 #### 2.1.4 系统处理逻辑 **价格自动更新流程:** ``` 每日收盘后: 1. 获取原始货币价格(如港币160) 2. 获取当前汇率(如0.9) 3. 转换为人民币(160 × 0.9 = ¥144) 4. 更新到数据库的 current_price 字段 ``` **计算逻辑:** ```typescript // 所有计算统一为人民币 const costValue = shares * costPrice; // 人民币成本 const marketValue = shares * currentPrice; // 人民币市值 const profit = marketValue - costValue; // 人民币盈亏 const profitPercent = (profit / costValue) * 100; const assetPercent = (marketValue / totalAsset) * 100; ``` #### 2.1.5 优点 - ✅ **极简**:用户只需输入人民币价格,操作简单 - ✅ **统一**:所有数据统一为人民币,计算和展示一致 - ✅ **无汇率问题**:完全避免汇率存储、更新、同步等问题 - ✅ **符合记账场景**:用户记录的是自己的资产情况,通常知道人民币成本 - ✅ **实现简单**:无需复杂的汇率处理逻辑 #### 2.1.6 缺点 - ⚠️ **用户需要转换**:持有港股/美股的用户需要自己将港币/美元价格转换为人民币 - ⚠️ **无法直接核对**:无法直接与市场报价(原始货币)对比 #### 2.1.7 适用场景 - ✅ 纯手动记账 - ✅ 用户主要关心人民币价值 - ✅ 记账软件定位(非实时交易软件) --- ### 2.2 方案B:混合计价方案(对比参考) #### 2.2.1 核心思路 - **数据存储**:同时存储人民币价格和原始币种价格 - **用户输入**:可以选择输入人民币价格或原始币种价格 - **系统处理**:自动转换和同步两种价格 - **展示策略**:主要显示人民币,辅助显示原始币种 #### 2.2.2 数据存储设计 **数据库字段:** ```sql -- positions 表 cost_price DECIMAL(18, 4) -- 成本价(人民币,主要) current_price DECIMAL(18, 4) -- 最新价(人民币,主要) cost_price_original DECIMAL(18, 4) -- 成本价(原始币种,可选) current_price_original DECIMAL(18, 4) -- 最新价(原始币种,可选) cost_exchange_rate DECIMAL(10, 6) -- 成本汇率(加权平均) current_exchange_rate DECIMAL(10, 6) -- 当前汇率 exchange_rate_updated_at TIMESTAMP -- 汇率更新时间 shares DECIMAL(18, 4) currency VARCHAR(10) ``` #### 2.2.3 用户操作流程 **创建持仓(三种方式):** **方式1:直接输入人民币价格(推荐)** ``` 成本价:¥135(人民币) 最新价:¥144(人民币) 数量:100股 ``` **方式2:输入原始币种 + 人民币总成本** ``` 成本价:HK$150(原始币种) 数量:100股 人民币总成本:¥13,500(必须输入) → 系统自动计算加权平均汇率:13,500 / (100 × 150) = 0.9 → 系统自动计算人民币成本价:150 × 0.9 = ¥135 ``` **方式3:输入原始币种 + 加权平均汇率** ``` 成本价:HK$150(原始币种) 数量:100股 加权平均汇率:0.9(用户自己计算) → 系统自动计算人民币成本价:150 × 0.9 = ¥135 ``` #### 2.2.4 系统处理逻辑 **汇率计算:** - 成本汇率:从人民币总成本反推加权平均汇率 - 当前汇率:每日自动更新,用于价格转换 **价格更新:** ``` 每日收盘后: 1. 获取原始货币价格(如港币160) 2. 获取当前汇率(如0.9) 3. 转换为人民币(160 × 0.9 = ¥144) 4. 同时更新 current_price(人民币)和 current_price_original(原始币种) ``` #### 2.2.5 优点 - ✅ 支持自动更新价格(可以获取市场原始数据) - ✅ 可以核对市场报价(显示原始币种价格) - ✅ 用户可以选择输入方式 #### 2.2.6 缺点 - ⚠️ **复杂**:需要存储和管理汇率 - ⚠️ **多次买入问题**:需要处理加权平均汇率 - ⚠️ **汇率同步**:需要定期更新汇率 - ⚠️ **实现复杂**:需要处理汇率转换逻辑 #### 2.2.7 适用场景 - ✅ 需要自动更新价格 - ✅ 用户需要核对市场报价 - ✅ 需要显示原始币种价格 --- ## 三、方案选择 ### 3.1 最终选择:方案A(统一人民币存储方案) **选择理由:** 1. **符合记账软件定位**:用户记录的是自己的资产情况,通常知道人民币成本 2. **简化用户操作**:用户只需输入3个数字(成本价、最新价、数量),无需考虑汇率 3. **简化系统实现**:无需存储和管理汇率,计算逻辑简单 4. **满足最终需求**:总资产、总成本、持仓占比都统一为人民币 ### 3.2 关于价格更新的处理 **方式1:系统自动更新(推荐)** - 系统获取原始货币价格 - 自动转换为人民币 - 更新到数据库 **方式2:用户手动更新** - 用户直接在界面上更新人民币价格 --- ## 四、产品需求文档 ### 4.1 新建持仓 #### 4.1.1 功能描述 用户创建新的持仓记录,支持两种创建方式: 1. **快速创建**:通过搜索框搜索资产代码或名称,选择后自动填充基本信息 2. **手动创建**:手动选择资产类型,然后填写所有信息 两种方式默认都支持,用户可以根据习惯选择。 #### 4.1.2 用户角色 所有注册用户 #### 4.1.3 功能入口 - 持仓列表页面 → "新增持仓" 按钮 - 顶部导航栏 → "资产" → "新增持仓" #### 4.1.4 弹窗设计 **弹窗布局:** - **PC端**:居中弹窗(Modal),宽度:600px - **移动端**:底部弹窗(Drawer),高度:80% **弹窗结构(从上到下):** 1. **搜索框**(顶部,始终显示) - 位置:弹窗最顶部 - 功能:支持全局搜索资产代码或名称 - 交互: - 用户输入代码(如:600519、00700、AAPL)或名称(如:贵州茅台、腾讯控股) - 系统实时联想,显示匹配结果 - 联想结果包含:资产代码、资产名称、市场、资产类型 - 用户选择后,自动填充:资产类型、市场、资产代码、资产名称 - 填充后,搜索框显示选中的资产名称(可点击重新搜索) 2. **资产类型选择**(搜索框下方) - 如果通过搜索选择了资产,此字段自动填充且禁用 - 如果未通过搜索选择,用户手动选择:股票/基金/债券/现金/其他 3. **市场和券商**(根据资产类型显示) - **股票/基金/债券**:显示"市场"和"券商"字段 - **现金**:只显示"券商"字段,不显示"市场"字段 - 如果通过搜索选择了资产,"市场"字段自动填充 4. **资产代码和资产名称**(根据资产类型显示) - **股票/基金/债券**:显示"资产代码"和"资产名称"字段 - **现金**:不显示这两个字段 - 如果通过搜索选择了资产,这两个字段自动填充且禁用 5. **价格和数量信息** - **成本价(人民币)**:数字输入,单位:元(必填) - **持股数量**:数字输入(必填) - **最新价(人民币)**:数字输入,单位:元(可选) 6. **其他选项** - **货币类型**:下拉选择(人民币/港币/美元),默认:人民币 - **是否自动更新价格**:开关,默认:关闭 #### 4.1.5 输入字段详细说明 **搜索框:** - **功能**:全局搜索资产代码或名称 - **搜索范围**:股票、基金、债券(不包含现金) - **联想结果格式**: ``` 600519 - 贵州茅台 (A股-上海) 00700 - 腾讯控股 (港股) AAPL - 苹果公司 (美股) ``` - **选择后行为**: - 自动填充:资产类型、市场、资产代码、资产名称 - 搜索框显示选中的资产名称(可点击重新搜索) - 相关字段变为禁用状态(可点击"重新选择"按钮恢复) **资产类型:** - **选项**:股票、基金、债券、现金、其他 - **默认值**:无(必须选择) - **联动规则**: - 选择"股票/基金/债券":显示市场、券商、资产代码、资产名称字段 - 选择"现金":只显示券商字段,隐藏市场、资产代码、资产名称字段 **市场:** - **选项**:A股-上海、A股-深圳、港股、美股等 - **显示条件**:资产类型为股票/基金/债券时显示 - **自动填充**:如果通过搜索选择了资产,自动填充对应市场 **券商:** - **选项**:从用户已添加的券商中选择 - **显示条件**:所有资产类型都显示 - **必填**:是 **资产代码:** - **输入方式**:文本输入 - **显示条件**:资产类型为股票/基金/债券时显示 - **自动填充**:如果通过搜索选择了资产,自动填充且禁用 - **示例**:600519、00700、AAPL **资产名称:** - **输入方式**:文本输入 - **显示条件**:资产类型为股票/基金/债券时显示 - **自动填充**:如果通过搜索选择了资产,自动填充且禁用 - **示例**:贵州茅台、腾讯控股、苹果公司 **成本价(人民币):** - **输入方式**:数字输入,支持小数 - **单位**:元 - **必填**:是 - **说明**:用户从券商系统看到的成本价,如果是港股/美股,需要转换为人民币后输入 - **示例**:1600.00 **持股数量:** - **输入方式**:数字输入,支持小数 - **必填**:是 - **说明**:当前持仓的股数/份数 - **示例**:100 **最新价(人民币):** - **输入方式**:数字输入,支持小数 - **单位**:元 - **必填**:否 - **说明**:当前市场价格,如果是港股/美股,需要转换为人民币后输入 - **示例**:1850.00 **货币类型:** - **选项**:人民币、港币、美元 - **默认值**:人民币 - **说明**:仅用于标识市场,不影响计算和展示 **是否自动更新价格:** - **类型**:开关 - **默认值**:关闭 - **说明**:开启后,系统每日收盘后自动更新最新价 #### 4.1.5 业务规则 1. **搜索规则**: - 搜索框支持全局搜索股票、基金、债券(不包含现金、其他) - 搜索范围:资产代码、资产名称 - 搜索方式:模糊匹配,不区分大小写 - 联想结果最多显示10条 - 联想结果按匹配度排序(完全匹配 > 前缀匹配 > 包含匹配) - **智能过滤**:如果用户选择了资产类型,搜索框自动过滤,只搜索该类型 - **防抖处理**:输入后300ms才触发搜索,避免频繁请求 2. **资产类型联动规则**: - 用户选择资产类型后,搜索框自动过滤该类型 - 搜索框占位符动态变化:"搜索股票..."、"搜索基金..."等 - 如果用户清空资产类型选择,搜索框恢复全局搜索 - 如果通过搜索选择了资产,资产类型自动填充且禁用 3. **自动填充规则**: - 通过搜索选择资产后,自动填充:资产类型、市场、资产代码、资产名称、货币类型 - 自动填充的字段变为禁用状态,显示锁定图标,防止误修改 - 用户可以通过"重新选择"按钮清空并重新搜索 - 如果用户手动修改了资产类型,清空自动填充的字段(资产类型、市场、代码、名称) 4. **字段显示规则**: - **资产类型为股票/基金/债券**: - 显示:市场、券商、资产代码、资产名称、成本价、持股数量、最新价 - **资产类型为现金**: - 显示:券商、成本价、持股数量、最新价 - 隐藏:市场、资产代码、资产名称 - **资产类型为其他**: - 显示:券商、资产代码、资产名称、成本价、持股数量、最新价 - 隐藏:市场(可选) 5. **搜索失败处理规则**: - 搜索无结果时,显示提示:"未找到匹配的资产,点击手动输入" - 用户点击后,可以: - 选择隐藏搜索框,显示完整表单 - 或保留搜索框,但允许用户手动填写所有字段 - 用户可以继续完成创建流程 6. **唯一性约束**: - 同一用户、同一券商、同一资产代码、同一市场、同一资产类型只能有一条持仓 - 如果已存在,提示用户"该持仓已存在,请直接编辑现有持仓" - 现金类型:同一用户、同一券商只能有一条现金持仓 7. **价格输入规则**: - 成本价和最新价必须使用人民币计价 - 如果持有港股/美股,用户需要自己转换为人民币后输入 - 系统不提供汇率转换工具(简化设计) - 价格支持小数,精度:2位小数 8. **数量规则**: - 持股数量必须大于0 - 支持小数(如基金份额) - 精度:4位小数 9. **价格更新规则**: - 如果开启自动更新,系统每日收盘后自动更新最新价 - 更新时:获取原始货币价格 → 转换为人民币 → 更新 - 现金类型不支持自动更新价格 10. **表单验证规则**: - 搜索选择资产后,资产类型、市场、资产代码、资产名称自动填充,无需验证 - 手动创建时,资产类型、券商、成本价、持股数量为必填 - 股票/基金/债券类型,市场、资产代码、资产名称为必填 - 现金类型,不需要市场、资产代码、资产名称 - 其他类型,资产代码、资产名称可选 #### 4.1.6 用户提示 **表单提示信息:** ``` 💡 提示: • 成本价和最新价请使用人民币计价 • 如果持有港股/美股,请将港币/美元价格转换为人民币后输入 • 系统会自动更新价格(如果开启自动更新) • 成本价是您从券商系统看到的加权平均成本价 ``` #### 4.1.7 交互流程 **方式1:快速创建(通过搜索)** 1. 用户点击"新增持仓"按钮 2. 弹出表单对话框(PC端居中,移动端底部) 3. 用户在顶部搜索框输入资产代码或名称(如:600519 或 贵州茅台) 4. 系统实时联想,显示匹配结果列表(最多10条) 5. 用户从联想结果中选择一个资产 6. 系统自动填充: - 资产类型(自动填充且禁用) - 市场(自动填充) - 资产代码(自动填充且禁用) - 资产名称(自动填充且禁用) - 搜索框显示选中的资产名称,右侧显示"重新选择"按钮 7. 用户选择券商 8. 用户输入成本价(人民币) 9. 用户输入持股数量 10. 用户输入最新价(可选) 11. 用户设置其他选项(货币类型、自动更新价格) 12. 用户点击"保存"按钮 13. 系统验证数据 14. 系统保存持仓 15. 刷新持仓列表 16. 显示成功提示 **方式2:智能创建(先选类型再搜索)** 1. 用户点击"新增持仓"按钮 2. 弹出表单对话框(PC端居中,移动端底部) 3. 用户先选择资产类型(如:股票) 4. 搜索框自动过滤,只搜索该类型的资产 5. 用户在搜索框输入资产代码或名称 6. 系统实时联想,显示匹配结果列表(仅显示该类型) 7. 用户从联想结果中选择一个资产 8. 系统自动填充相关信息(同方式1) 9. 用户填写剩余字段(券商、成本价、数量、最新价等) 10. 用户点击"保存"按钮 11. 系统验证数据并保存 **方式3:手动创建(不通过搜索)** 1. 用户点击"新增持仓"按钮 2. 弹出表单对话框(PC端居中,移动端底部) 3. 用户选择资产类型(股票/基金/债券/现金/其他) 4. 根据资产类型显示对应字段: - **股票/基金/债券**:显示市场、券商、资产代码、资产名称 - **现金**:只显示券商 5. 用户填写必填字段: - 选择市场(股票/基金/债券必填) - 选择券商(所有类型必填) - 输入资产代码(股票/基金/债券必填) - 输入资产名称(股票/基金/债券必填) - 输入成本价(所有类型必填) - 输入持股数量(所有类型必填) 6. 用户填写可选字段: - 输入最新价(可选) - 选择货币类型(默认人民币) - 设置是否自动更新价格(默认关闭) 7. 用户点击"保存"按钮 8. 系统验证数据 9. 系统保存持仓 10. 刷新持仓列表 11. 显示成功提示 **搜索失败处理流程:** 1. 用户输入搜索关键词 2. 系统搜索无结果 3. 显示提示:"未找到匹配的资产,点击手动输入" 4. 用户点击"手动输入" 5. 搜索框变为可选(可隐藏或保留) 6. 显示完整表单,用户可以手动填写所有字段 7. 用户可以继续完成创建流程 **搜索框交互细节:** - **输入时**: - 用户输入字符,系统实时搜索(防抖300ms) - 显示联想结果列表(最多10条) - 结果包含:资产代码、资产名称、市场、资产类型 - 高亮匹配的字符 - 如果选择了资产类型,只显示该类型的结果 - **选择后**: - 搜索框显示选中的资产名称(可点击重新搜索) - 自动填充相关字段(资产类型、市场、代码、名称) - 填充的字段变为禁用状态,显示锁定图标 - 搜索框右侧显示"重新选择"按钮 - **重新选择**: - 点击"重新选择"按钮或点击搜索框 - 清空已填充的字段 - 恢复字段为可编辑状态 - 搜索框恢复为空,可重新输入 - 如果之前选择了资产类型,搜索框恢复为该类型的过滤状态 - **资产类型联动**: - 用户选择资产类型(如:股票) - 搜索框自动过滤,只搜索股票类型 - 搜索框占位符变为"搜索股票..." - 如果用户清空资产类型选择,搜索框恢复全局搜索 - **搜索框状态**: - **未选择**:显示占位符"输入代码/名称搜索..."或"搜索[资产类型]..." - **已选择**:显示选中的资产名称,右侧显示"重新选择"按钮 - **搜索中**:显示加载动画 - **无结果**:显示"未找到匹配的资产,点击手动输入" #### 4.1.8 异常处理 **搜索相关:** - **搜索无结果**: - 显示"未找到匹配的资产" - 提供"点击手动输入"按钮 - 点击后可以隐藏搜索框(可选),显示完整表单 - 用户可以继续手动填写所有字段完成创建 - **搜索网络错误**: - 显示"搜索失败,请稍后重试" - 允许用户继续手动填写 - 提供"重试"按钮 - **搜索超时**: - 显示"搜索超时,请稍后重试" - 允许用户继续手动填写 - 提供"重试"按钮 - **资产类型过滤后无结果**: - 提示"当前类型下未找到匹配的资产" - 提供"清除类型过滤"或"切换为手动输入"选项 **表单验证:** - **持仓已存在**:提示"该持仓已存在,请直接编辑现有持仓" - **必填字段为空**:表单验证提示,高亮显示未填写的必填字段 - **价格格式错误**:提示"请输入有效的价格(支持小数)" - **数量小于等于0**:提示"持股数量必须大于0" - **资产类型未选择**:提示"请选择资产类型或通过搜索选择资产" **数据一致性:** - **搜索选择的资产与手动选择的资产类型不一致**: - 如果用户通过搜索选择了股票,但手动将资产类型改为基金 - 清空自动填充的字段,提示"资产类型已更改,请重新填写相关信息" - **搜索选择的资产市场与手动选择的市场不一致**: - 提示"搜索选择的资产市场与手动选择的市场不一致,请确认" - 允许用户选择使用哪个市场 --- ### 4.2 编辑持仓 #### 4.2.1 功能描述 用户修改现有持仓的信息,主要是更新成本价、最新价、持股数量。 #### 4.2.2 用户角色 所有注册用户(只能编辑自己的持仓) #### 4.2.3 功能入口 - 持仓列表页面 → 点击持仓卡片 → "编辑" 按钮 - 持仓详情页面 → "编辑" 按钮 #### 4.2.4 可编辑字段 **可编辑字段:** - **券商**:下拉选择(可修改) - **成本价(人民币)**:数字输入,单位:元 - 说明:修改加权平均成本价 - **最新价(人民币)**:数字输入,单位:元 - 说明:修改当前市场价格 - **持股数量**:数字输入 - 说明:修改持仓数量 - **货币类型**:下拉选择(可修改) - **是否自动更新价格**:开关(可修改) - **状态**:下拉选择(活跃/暂停/退市) **不可编辑字段:** - 资产类型 - 资产代码 - 资产名称 - 市场(部分系统可能允许修改) #### 4.2.5 业务规则 1. **价格更新规则**: - 更新最新价时,系统自动将旧价格保存到 `previous_price` 字段 - 用于显示涨跌颜色(红色/绿色) 2. **数量更新规则**: - 数量可以增加(加仓)或减少(减仓) - 数量不能小于0 3. **成本价更新规则**: - 如果用户修改了成本价,说明可能是加仓或减仓 - 系统记录变更历史(可选功能) #### 4.2.6 用户提示 **表单提示信息:** ``` 💡 提示: • 修改成本价、最新价、数量后,系统会自动重新计算盈亏和占比 • 成本价和最新价请使用人民币计价 • 如果修改了成本价或数量,建议检查成本价是否正确(加权平均) ``` #### 4.2.7 交互流程 1. 用户点击"编辑"按钮 2. 弹出编辑表单对话框(预填充现有数据) 3. 用户修改字段 4. 用户点击"保存"按钮 5. 系统验证数据 6. 系统更新持仓 7. 刷新持仓列表 8. 显示成功提示 #### 4.2.8 异常处理 - **持仓不存在**:提示"持仓不存在" - **无权限**:提示"您没有权限编辑此持仓" - **数据验证失败**:表单验证提示 --- ### 4.3 价格自动更新 #### 4.3.1 功能描述 系统每日收盘后自动更新开启了"自动更新价格"的持仓的最新价。 #### 4.3.2 触发时机 - 每个工作日下午4点(A股收盘后) - 可配置定时任务 #### 4.3.3 更新流程 1. **查询需要更新的持仓**: ```sql SELECT * FROM positions WHERE auto_price_update = true AND status = 'active' AND asset_type IN ('stock', 'fund') ``` 2. **获取市场价格**: - 调用市场数据API获取原始货币价格 - 根据 `currency` 字段判断币种 3. **转换为人民币**: - 如果 `currency = 'CNY'`:直接使用价格 - 如果 `currency != 'CNY'`: - 获取当前汇率 - 转换为人民币:`人民币价格 = 原始货币价格 × 汇率` 4. **更新数据库**: - 将旧价格保存到 `previous_price` - 更新 `current_price` 为人民币价格 - 更新 `updated_at` 时间戳 #### 4.3.4 异常处理 - **市场数据获取失败**:记录日志,跳过该持仓 - **汇率获取失败**:使用上次汇率或跳过更新 - **价格异常**:记录日志,不更新 --- ## 五、技术实现细节 ### 5.1 数据库设计 #### 5.1.1 表结构 ```sql CREATE TABLE positions ( position_id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, broker_id BIGINT NOT NULL REFERENCES brokers(broker_id), asset_type VARCHAR(20) NOT NULL CHECK (asset_type IN ('stock', 'fund', 'cash', 'bond', 'other')), symbol VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, market VARCHAR(20), shares DECIMAL(18, 4) NOT NULL DEFAULT 0, cost_price DECIMAL(18, 4) NOT NULL, -- 成本价(人民币) current_price DECIMAL(18, 4), -- 最新价(人民币) previous_price DECIMAL(18, 4), -- 上一次价格(人民币) currency VARCHAR(10) NOT NULL DEFAULT 'CNY', -- 货币类型(仅标识) auto_price_update BOOLEAN NOT NULL DEFAULT false, status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'delisted')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, broker_id, symbol, market, asset_type) ); ``` #### 5.1.2 索引设计 ```sql CREATE INDEX idx_positions_user_id ON positions(user_id); CREATE INDEX idx_positions_broker_id ON positions(broker_id); CREATE INDEX idx_positions_status ON positions(status); CREATE INDEX idx_positions_auto_update ON positions(auto_price_update) WHERE auto_price_update = true; ``` ### 5.2 后端实现 #### 5.2.1 搜索资产接口 ```typescript GET /api/assets/search?keyword=600519&assetType=stock&limit=10 Request Query: - keyword: string (必填) - 搜索关键词(资产代码或名称) - assetType: string (可选) - 资产类型过滤(stock/fund/bond) - 如果提供,只搜索该类型的资产 - 如果不提供,搜索所有类型(股票、基金、债券) - limit: number (可选) - 返回结果数量,默认10 Response: { code: 0, data: [ { symbol: string; // 资产代码 name: string; // 资产名称 market: string; // 市场(sh/sz/hk/us等) assetType: string; // 资产类型(stock/fund/bond) currency: string; // 货币类型(CNY/HKD/USD) } ] } ``` **搜索逻辑:** - 搜索范围:股票、基金、债券(不包含现金、其他) - 搜索字段:资产代码、资产名称 - 匹配方式:模糊匹配,不区分大小写 - 排序规则:完全匹配 > 前缀匹配 > 包含匹配 - **智能过滤**: - 如果提供了 `assetType` 参数,只搜索该类型的资产 - 如果没有提供,搜索所有类型(股票、基金、债券) - 搜索结果按匹配度排序,同类型优先 **后端实现示例:** ```typescript @Get('search') async searchAssets( @Query('keyword') keyword: string, @Query('assetType') assetType?: string, @Query('limit') limit: number = 10, ) { const query = this.assetRepository .createQueryBuilder('asset') .where('asset.symbol LIKE :keyword OR asset.name LIKE :keyword', { keyword: `%${keyword}%`, }) .andWhere('asset.assetType IN (:...types)', { types: assetType ? [assetType] : ['stock', 'fund', 'bond'], }) .orderBy('CASE WHEN asset.symbol = :exact THEN 1 WHEN asset.symbol LIKE :prefix THEN 2 ELSE 3 END', 'ASC') .addOrderBy('asset.name', 'ASC') .limit(limit); return query.getMany(); } ``` #### 5.2.2 创建持仓接口 ```typescript POST /api/positions Request Body: { brokerId: number; assetType: string; symbol: string; // 资产代码(股票/基金/债券必填,现金不需要) name: string; // 资产名称(股票/基金/债券必填,现金不需要) market?: string; // 市场(股票/基金/债券可选,现金不需要) shares: number; costPrice: number; // 人民币成本价 currentPrice?: number; // 人民币最新价 currency?: string; // 仅标识,默认CNY autoPriceUpdate?: boolean; status?: string; } Response: { code: 0, data: { positionId: number; // ... 其他字段 } } ``` **字段验证规则:** - 如果 `assetType` 为 `stock/fund/bond`: - `symbol`、`name` 必填 - `market` 可选(但建议填写) - 如果 `assetType` 为 `cash`: - `symbol`、`name`、`market` 不需要 #### 5.2.2 更新持仓接口 ```typescript PUT /api/positions/:id Request Body: { brokerId?: number; costPrice?: number; // 人民币成本价 currentPrice?: number; // 人民币最新价 shares?: number; currency?: string; autoPriceUpdate?: boolean; status?: string; } Response: { code: 0, data: { positionId: number; // ... 更新后的字段 } } ``` #### 5.2.3 查询持仓列表接口 ```typescript GET /api/positions Response: { code: 0, data: [ { positionId: number; costPrice: number; // 人民币成本价 currentPrice: number; // 人民币最新价 shares: number; costValue: number; // 人民币成本 = shares * costPrice marketValue: number; // 人民币市值 = shares * currentPrice profit: number; // 人民币盈亏 = marketValue - costValue profitPercent: number; // 盈亏比例 assetPercent: number; // 持仓占比 // ... 其他字段 } ] } ``` #### 5.2.4 价格自动更新服务 ```typescript @Injectable() export class PriceUpdateService { /** * 每日收盘后更新价格 */ @Cron('0 16 * * 1-5') async updateDailyPrices() { const positions = await this.getPositionsToUpdate(); for (const position of positions) { try { // 1. 获取原始货币价格 const originalPrice = await this.getMarketPrice( position.symbol, position.market ); // 2. 转换为人民币 let cnyPrice = originalPrice; if (position.currency !== 'CNY') { const exchangeRate = await this.getCurrentExchangeRate( position.currency, 'CNY' ); cnyPrice = originalPrice * exchangeRate; } // 3. 更新持仓 await this.updatePositionPrice( position.positionId, cnyPrice ); } catch (error) { this.logger.error(`更新价格失败: ${position.symbol}`, error); } } } } ``` ### 5.3 前端实现 #### 5.3.1 新建持仓表单 ```typescript const CreatePositionForm = () => { const [searchKeyword, setSearchKeyword] = useState(''); const [searchResults, setSearchResults] = useState([]); const [selectedAsset, setSelectedAsset] = useState(null); const [assetType, setAssetType] = useState(''); const [isSearching, setIsSearching] = useState(false); const [showManualInput, setShowManualInput] = useState(false); const form = Form.useForm(); // 防抖搜索 const debouncedSearch = useMemo( () => debounce(async (keyword: string, type?: string) => { if (!keyword || keyword.length < 1) { setSearchResults([]); return; } setIsSearching(true); try { const params: any = { keyword, limit: 10 }; // 如果选择了资产类型,只搜索该类型 if (type && type !== 'cash' && type !== 'other') { params.assetType = type; } const response = await api.get('/api/assets/search', { params }); setSearchResults(response.data || []); } catch (error) { console.error('搜索失败', error); setSearchResults([]); } finally { setIsSearching(false); } }, 300), [] ); // 搜索资产 const handleSearch = (keyword: string) => { setSearchKeyword(keyword); debouncedSearch(keyword, assetType); }; // 选择资产 const handleSelectAsset = (asset: any) => { setSelectedAsset(asset); setSearchKeyword(asset.name); setSearchResults([]); setShowManualInput(false); // 自动填充表单 form.setFieldsValue({ assetType: asset.assetType, market: asset.market, symbol: asset.symbol, name: asset.name, currency: asset.currency, }); setAssetType(asset.assetType); }; // 重新选择 const handleResetSearch = () => { setSelectedAsset(null); setSearchKeyword(''); setSearchResults([]); form.setFieldsValue({ assetType: assetType || undefined, market: undefined, symbol: undefined, name: undefined, }); }; // 资产类型变化 const handleAssetTypeChange = (value: string) => { setAssetType(value); form.setFieldsValue({ assetType: value }); // 如果手动修改了资产类型,清空搜索选择 if (selectedAsset && selectedAsset.assetType !== value) { handleResetSearch(); } // 如果选择了现金或其他,隐藏搜索框 if (value === 'cash' || value === 'other') { setShowManualInput(true); } else { setShowManualInput(false); } // 如果搜索框有内容,重新搜索(过滤类型) if (searchKeyword) { handleSearch(searchKeyword); } }; // 获取搜索框占位符 const getSearchPlaceholder = () => { if (assetType === 'stock') return '搜索股票...'; if (assetType === 'fund') return '搜索基金...'; if (assetType === 'bond') return '搜索债券...'; return '输入代码/名称搜索...'; }; return (
{/* 搜索框(现金和其他类型时隐藏) */} {!showManualInput && ( ({ value: `${asset.symbol} - ${asset.name}`, label: (
{asset.symbol} - {asset.name}
{getMarketName(asset.market)} - {getAssetTypeName(asset.assetType)}
), asset: asset, }))} onSearch={handleSearch} onSelect={(value, option) => handleSelectAsset(option.asset)} placeholder={getSearchPlaceholder()} allowClear disabled={!!selectedAsset} notFoundContent={ searchKeyword && !isSearching ? (
未找到匹配的资产
) : null } /> {selectedAsset && (
{selectedAsset.name}
)}
)} {/* 资产类型 */} {/* 市场和券商(股票/基金/债券显示) */} {(assetType === 'stock' || assetType === 'fund' || assetType === 'bond') && ( <> )} {/* 券商(现金显示) */} {assetType === 'cash' && ( )} {/* 价格和数量 */} {/* 其他选项 */}

• 可以通过搜索框快速选择资产,系统会自动填充相关信息

• 成本价和最新价请使用人民币计价

• 如果持有港股/美股,请将港币/美元价格转换为人民币后输入

• 系统会自动更新价格(如果开启自动更新)

} type="info" showIcon /> ); }; ``` #### 5.3.2 持仓列表展示 ```typescript const PositionList = () => { const { positions, totalAssetCNY } = usePositions(); return (
{/* 总资产 */}

总资产

¥{totalAssetCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{/* 持仓列表 */} {positions.map(position => (
{position.name}
{position.symbol} {position.market && {getMarketName(position.market)}}
持股:{position.shares.toLocaleString()} 股
市值:¥{position.marketValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
成本:¥{position.costValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
= 0 ? 'positive' : 'negative'}`}> 盈亏:¥{position.profit.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ({position.profitPercent >= 0 ? '+' : ''}{position.profitPercent.toFixed(2)}%)
占比:{position.assetPercent.toFixed(2)}%
))}
); }; ``` --- ## 六、总结 ### 6.1 最终方案 采用**统一人民币存储方案**,所有价格统一使用人民币计价,简化用户操作和系统实现。 ### 6.2 核心要点 1. **用户输入**:所有价格统一使用人民币 2. **数据存储**:数据库中所有价格字段统一为人民币 3. **系统处理**:所有计算和展示统一为人民币 4. **价格更新**:系统自动获取原始货币价格并转换为人民币 ### 6.3 优势 - ✅ 极简:用户操作简单,只需输入3个数字 - ✅ 统一:所有数据统一为人民币,计算和展示一致 - ✅ 无汇率问题:完全避免汇率存储、更新、同步等问题 - ✅ 符合记账场景:用户记录的是自己的资产情况 ### 6.4 后续优化方向 1. 提供简单的汇率转换工具(可选) 2. 支持批量导入持仓(从Excel等) 3. 支持持仓变更历史记录 4. 支持持仓占比图表展示 --- --- ## 七、方案A实施要点总结 ### 7.1 核心优势 1. **灵活性**:支持三种创建方式,用户可以根据习惯选择 - 快速创建:直接搜索,自动填充 - 智能创建:先选类型再搜索,精准过滤 - 手动创建:完全手动输入,适合特殊资产 2. **容错性**:搜索失败可以无缝切换到手动输入 - 搜索无结果时,提供手动输入选项 - 网络错误时,允许用户继续操作 - 不会因为搜索功能影响基本功能 3. **智能联动**:搜索框和资产类型相互联动 - 选择资产类型后,搜索框自动过滤 - 搜索选择后,资产类型自动填充 - 字段根据选择动态显示/隐藏 4. **用户体验**:渐进式披露,不会一次性显示所有字段 - 先显示搜索框和资产类型 - 根据选择逐步显示对应字段 - 减少用户认知负担 ### 7.2 实现要点 1. **搜索功能**: - 实现防抖搜索(300ms) - 支持资产类型过滤 - 搜索结果按匹配度排序 - 处理搜索失败情况 2. **字段联动**: - 搜索选择后自动填充字段 - 字段禁用状态管理 - 资产类型变化时清空相关字段 3. **表单验证**: - 根据资产类型动态验证 - 搜索选择后跳过相关字段验证 - 手动输入时完整验证 4. **用户体验优化**: - 搜索框占位符动态变化 - 搜索状态提示(加载中、无结果等) - 提供"重新选择"和"手动输入"选项 ### 7.3 技术实现建议 1. **前端**: - 使用 `AutoComplete` 组件实现搜索 - 使用 `Form` 组件管理表单状态 - 使用 `useMemo` 和 `debounce` 优化搜索性能 - 使用状态管理控制字段显示/隐藏 2. **后端**: - 实现搜索接口,支持关键词和类型过滤 - 使用数据库索引优化搜索性能 - 实现搜索结果缓存(可选) 3. **数据准备**: - 准备资产数据库(股票、基金、债券) - 包含:代码、名称、市场、类型等信息 - 定期更新资产数据 --- **文档版本**:v1.1 **最后更新**:2024年 **作者**:AI Assistant **更新说明**:采用方案A(搜索优先 + 智能切换)设计