commit 9ed161d93ebdf29799f34c2d08221d54769edd51 Author: justin Date: Sat Oct 10 09:21:25 2026 +0800 Initial commit: G3FO recurring investment schedule frontend Co-authored-by: Cursor diff --git a/.cursor/rules/project-core.mdc b/.cursor/rules/project-core.mdc new file mode 100644 index 0000000..225805d --- /dev/null +++ b/.cursor/rules/project-core.mdc @@ -0,0 +1,22 @@ +--- +description: schedule-invest-plan 技术栈与全局约定(URL 语言/主题、文档) +alwaysApply: true +--- + +# 项目核心约定 + +- 栈:Vue 3 + TypeScript + Vite、Vue Router、Pinia、Axios、vue-i18n、Sass、ECharts、Element Plus(按需自动导入)。 +- 代码修改须同步维护 `IMPACT_ANALYSIS.md`、`TASK_SUMMARY.md`(团队规范)。 + +## 语言与主题(仅 URL) + +- 查询参数:`lang`(或 `locale`)、`theme`;**禁止**用 `localStorage` 持久化语言/主题。 +- 缺省:`lang=zh-TW`、`theme=dark`;`theme` 支持别名 `light`/`day`、`dark`/`night`。 +- 解析与写入:`src/config/urlPreferences.ts`;切换语言/主题用 `useUrlPreferences()`(更新 `router.replace` 的 query)。 +- 路由跳转保留 query:`useRouteWithPreferences().linkTo(path)` 或等价 `{ path, query: route.query }`。 +- i18n:`src/i18n/`(简/繁/英);Element Plus 语言:`getElementPlusLocale` + 根节点 `el-config-provider`。 +- 深色:`applyTheme` 设置 `data-theme` 与 `html.dark`(Element Plus 深色变量)。 + +## 详细规范文档 + +- 响应式:`docs/RESPONSIVE_SPEC.md`(断点、布局、禁止事项以该文档为准)。 diff --git a/.cursor/rules/responsive-layout.mdc b/.cursor/rules/responsive-layout.mdc new file mode 100644 index 0000000..dfc2110 --- /dev/null +++ b/.cursor/rules/responsive-layout.mdc @@ -0,0 +1,51 @@ +--- +description: PC/移动端响应式与布局(Mobile First) +globs: src/**/*.{vue,scss} +alwaysApply: false +--- + +# 响应式与布局 + +完整条文见 `docs/RESPONSIVE_SPEC.md`。实现时必须遵守: + +## 断点(勿私自改数值) + +| 令牌 | 宽度 | +|------|------| +| sm | 576px | +| md | 768px | +| lg | 1024px | +| xl | 1280px | + +- TS 真相:`src/constants/breakpoints.ts`;SCSS:`src/styles/breakpoints.scss`(两处数值一致)。 + +## 样式 + +- **移动优先**:默认小屏样式,用 `@include respond-up(md|lg)` 增强。 +- **禁止**业务里手写零散 `@media (max-width: 750px)`;Vite 已全局注入 `respond-up/down/between`。 +- 颜色/背景用 `var(--color-*)`;页面边距用 `var(--page-padding-x/y)`,勿写死 `padding: 32px`。 +- 可点击区域最小 **44×44px**。 + +## 壳层结构 + +``` +.app-shell > .app-container > AppHeader + 页面 +``` + +- 路由视图根节点包 **`AppPage`**(`src/components/layout/AppPage.vue`)。 +- 内容最大宽度 `--layout-max-width`(1280px)。 + +## 脚本 + +```ts +import { useBreakpoint } from '@/composables/useBreakpoint' +const { isMobile, isDesktop } = useBreakpoint() +``` + +- 仅用于布局/组件属性(如表单 `label-position`);勿用 `width` 驱动业务逻辑。 +- 勿在页面重复 `window.addEventListener('resize')`(图表等组件内除外)。 + +## Element Plus + +- 栅格默认 `xs="24"`,再 `sm`/`lg` 分列;`gutter` 移动 12、PC 20 为宜。 +- 移动:`label-position="top"`;表格移动优先卡片或 `.app-table-scroll` 横滚。 diff --git a/.cursor/rules/vue-sfc-patterns.mdc b/.cursor/rules/vue-sfc-patterns.mdc new file mode 100644 index 0000000..8f2b12a --- /dev/null +++ b/.cursor/rules/vue-sfc-patterns.mdc @@ -0,0 +1,38 @@ +--- +description: Vue 单文件组件与本仓库写法 +globs: src/**/*.vue +alwaysApply: false +--- + +# Vue SFC 约定 + +## 结构 + +- 路由页:`views/*` 根节点使用 ``;标题/导语可用 `.app-page__title`、`.app-page__lead`。 +- 样式:` + + +
+ +
+
System Design Document
+

PRP (Periodic Recurring Program)

+

定期投资 — 系统设计方案

+
+ 项目: G3SF / G3SB + 版本: v1.3 + 日期: 2026-08-03 + 分类: Internal + 作者: Jagon Li +
+
+ + + +
+

1 项目概述 & 集成全景

+ +

1.1 业务背景

+

N2N-AFE 为券商和银行提供一体化交易解决方案已超过 43 年。随着零售投资者对「被动定投」和「自动化理财」需求的快速增长,券商客户迫切需要一套内嵌于现有交易系统的PRP(Periodic Recurring Program,定期投资)功能。

+

该模块允许投资者设定自动化定额定期投资,系统按设定频率通过券商自有交易通道执行下单,并由结算系统自动更新持仓,全程可追踪、可修改、可审计。

+ +

1.2 N2N-AFE 产品集成全景

+
+
+
投资者入口
+
eServices
+
Web 门户
+
+
+
员工操作
+
ProX
+
专业交易终端
+
+
+
员工操作
+
OMC
+
运营管理中心
+
+
+
后端处理
+
G3SF + G3SB
+
前台交易 + 后台结算
+
+
+ +

1.3 设计原则

+
+
+
01
+
嵌入式 Widget 架构
+
同一组件,跨 5 个宿主产品复用,响应式自适应
+
+
+
02
+
零外部依赖
+
不接入 IBKR 或第三方 API,完全走券商自有 OMS 通道
+
+
+
03
+
现有系统无缝集成
+
G3SF 管理计划/订单,G3SB 处理成交/结算(Settlement)/持仓更新 — 复用现有交易与结算基础设施
+
+
+
04
+
专业级 UI/UX
+
简洁、大气、信息密度适中,适配金融场景
+
+
+ +

1.4 核心业务流程图

+
+ 投资者 → 创建 PRP 计划 → G3SF 存储 → Scheduler 定时触发 → G3SF 生成订单 → 券商 OMS → G3SB 结算 → 更新持仓 → 邮件通知 → 投资者 +
+
+ +
+

2 功能需求规格

+ +

2.1 角色定义

+ + + + + + + +
角色描述权限范围
Investor券商终端投资者创建/修改/查看/暂停/恢复/取消 自己的 PRP 计划
Staff券商运营/客服人员监控全部计划、系统配置、报表导出、手动干预
Admin系统管理员市场规则配置、费率模板、通知模板、数据维护
+ +

2.2 投资者端功能(A 模块)

+ +
+
A1 — 创建 PRP 投资计划
+ + + + + + + + + + + + + + + + + +
步骤字段类型说明
1标的代码搜索框支持代码/名称搜索,自动补全,显示交易所+币种
2每次投入金额数值按币种单位
3投资频率下拉每日 / 每周 / 每月
4频率日选择器每周=选周一到周五;每月=选 1-28 号
如果所选日期为非交易日,订单将于下一个交易日执行
5开始日期日期选择必须大于当前日期(最小为 T+1)
6结束方式切换 + 选项 A: 设定结束日期(日期选择)
+ 选项 B: 设定投资次数(输入轮数,如 12 次 = 一年)
+ 如选择"无截止日期"(Good Till Cancel),请将结束日期留空 +
7碎股/零股处理单选向上取整 / 向下取整 / 四舍五入 / 允许碎股
+
确认前实时预览 → 展示预估执行次数、总投资额、费用估算
+
+ +
+
A2 — 查看和管理计划
+
    +
  • 计划列表,卡片式布局,显示:标的名称/代码、频率、累计投入、当前市值、盈亏%
  • +
  • 状��标签:进行中 已暂停 已完成 已取消
  • +
  • 计划详情页:参数完整展示 + 投资曲线图(累计投入 vs 市值/盈亏)
  • +
  • 执行历史明细表(分页、日期筛选、导出 CSV)
  • +
  • 投资进度条(已执行次数 / 总计划次数)
  • +
  • 持仓不足时的 PnL 展示逻辑: + 如果系统内持有的该标的股数 < PRP 累计购入的总股数(即部分股票已被卖出),PnL 按「当前持仓部分 + PRP 交易历史总盈亏」分别列示: +
    ① 当前持仓市值 vs 当前持仓成本 → 持仓 PnL +
    ② PRP 历史累计:总买入金额 vs 总卖出金额 + 剩余持仓市值 → PRP 总回报 +
  • +
+
+ +
+
A3 — 修改计划(进行中)
+ + + + + +
可修改字段不可修改字段
每次投入金额、频率、频率日、结束方式(日期/次数)、碎股处理方式标的代码、开始日期(若需改=取消重建)
+
修改即时生效(下次执行使用新参数),全部修改记录到审计表
+
+ +
+
+
A4 — 暂停/恢复/取消
+
暂停:计划保留,停止后续执行。恢复:继续按原参数执行。取消:永久终止。
+
+
+
A5 — 投资预测
+
输入预估年化回报率 → 输出总投资额、预估终值、预估收益、DCA 成本摊平曲线图
+
+
+ +
+ A5 示例:投资者设定每月投入 $1,000 买入 NVDA,持续 30 年(360 次),预估年化回报 12%。系统计算:总投资额 = $360,000;按 DCA + 复利计算,预估投资组合终值约 $3,520,000(约为总投入的 9.8 倍);累计收益约 $3,160,000。下方的双线图直观展示:即使每笔投入金额不大,透过长期纪律性定投 + 复利效应,30 年后投资组合价值可达总投入的 8-10 倍。系统计算:总投资额 = $36,000;按 10% 复利计算预估终值 ≈ $41,780;预估收益 ≈ $5,780;并展示「累计投入 vs 预估市值」累积曲线图。 +
+ +

2.3 员工端功能(B 模块)

+ +
+
+
B1 — 计划监控
+
    +
  • 全部计划总览(按状态/交易所/投资者筛选)
  • +
  • 今日待执行清单
  • +
  • 执行结果监控(成功/失败/异常)
  • +
  • 异常即时告警
  • +
+
+
+
B2 — 系统配置
+
    +
  • 市场参数管理
  • +
  • 交易时间窗口配置
  • +
  • 通知模板编辑
  • +
  • 风控规则设置
  • +
+
+
+ +
+
B3 — 报表中心
+
日度执行汇总 · 月度计划统计 · 费用/佣金明细 · 异常/失败清单
+
+
+ +
+

3 数据模型设计

+ +
+ 说明:投资者与账户管理 (investors / investor_accounts) 由现有系统处理,PRP 模块不重复设计。以下仅列 PRP 特有数据表。 +
+ +

3.1 投资计划主表 — plans

+ + + + + + + + + + + + + + + + + + + + + + + + +
字段类型约束说明
plan_idVARCHAR(32)PK如 PRP-20260801-0001
client_idVARCHAR(32)NOT NULL关联投资者(沿用现有系统 client_id)
account_idVARCHAR(32)NOT NULL关联交易账户
instrument_codeVARCHAR(20)NOT NULL标的代码(G3SF 标准格式)
exchange_idVARCHAR(10)NOT NULL交易所代码(沿用现有系统)
amount_per_timeDECIMAL(18,4)NOT NULL每次投入金额
frequencyENUMNOT NULLDAILY / WEEKLY / MONTHLY
frequency_dayTINYINTWEEKLY=1-5(周一到周五), MONTHLY=1-28
start_dateDATENOT NULL计划开始日期
end_dateDATENULLNULL=无限期;非空=指定结束日期
total_roundsINTNULL计划执行总次数(与 end_date 互斥,二选一)
fractional_handlingENUMNOT NULLROUND_UP / ROUND_DOWN / NEAREST / ALLOW_FRACTIONAL
statusENUMNOT NULLACTIVE / PAUSED / COMPLETED / CANCELLED
total_investedDECIMAL(18,4)DEFAULT 0累计已投入金额
total_unitsDECIMAL(18,6)DEFAULT 0累计已购份额
avg_costDECIMAL(18,6)DEFAULT 0平均成本价
execution_countINTDEFAULT 0已执行次数
created_atDATETIMENOT NULL
updated_atDATETIMENOT NULL
created_byVARCHAR(32)NOT NULLclient_id 或 staff_id
+ +
+ 公司行动处理:若标的发生公司行动产生并行交易代码(如供股产生供股权、拆股产生临时代码等),PRP 计划仅跟踪原 instrument_code,不自动切换至新代码。因公司行动产生的持仓或权益变化由 G3SB 结算系统按常规公司行动流程处理,不影响 PRP 的定投执行。 +
+ +

3.2 执行记录表 — plan_executions

+ + + + + + + + + + + + + + + + + + + + + +
字段类型说明
execution_idVARCHAR(32) PK如 EXEC-20260801-0001
plan_idVARCHAR(32) FK关联计划
scheduled_dateDATE计划执行日期
actual_timeDATETIME实际执行时间
amountDECIMAL(18,4)本次投入金额
execution_priceDECIMAL(18,6)实际成交均价
quantity_fullINT整股数量
quantity_fractionalDECIMAL(18,6)碎股数量(如有)
total_quantityDECIMAL(18,6)总份额
commissionDECIMAL(18,4)佣金
other_feesDECIMAL(18,4)其他费用
statusENUMPENDING / EXECUTED / PARTIAL / FAILED / SKIPPED
g3sf_order_idVARCHAR(32)G3SF 订单编号(可通过订单 ID 追溯全部关联信息)
trade_remarkVARCHAR(100)成交备注标记为 PRP 投资计划
error_codeVARCHAR(20)失败错误码:SHORTFUND / SUSPENDED / PRICE_REJECTED / SYSTEM_ERROR
error_msgTEXT失败详情
notification_sentBOOLEAN邮件通知是否已发送
+ +
+ 停牌处理:若计划执行日标的股票处于停牌状态,系统将标记执行记录为 REJECTED(error_code=SUSPENDED),发送邮件通知投资者"标的已停牌",该笔执行不会补单。系统在下一个计划执行周期继续按正常流程处理。 +
+ +

3.3 修改审计表 — plan_modifications

+ + + + + + + + + + + + + +
字段类型说明
mod_idVARCHAR(32) PK修改编号
plan_idVARCHAR(32) FK关联计划
field_changedVARCHAR(50)修改字段名
old_valueTEXT旧值(JSON)
new_valueTEXT新值(JSON)
modified_byVARCHAR(32)修改人
modified_by_roleENUMINVESTOR / STAFF / SYSTEM
modified_atDATETIME修改时间
reasonVARCHAR(200)修改原因
+ +

3.4 系统配置表 — system_config

+
+ 说明:系统配置相关表结构及字段细节由开发团队根据具体实现需求自行设计。本表仅供概念参考,不作为强制规范。 +
+ + + + + + + + +
字段类型说明
config_keyVARCHAR(100) PK配置键
config_valueTEXT配置值(JSON)
updated_byVARCHAR(32)更新人
updated_atDATETIME更新时间
+
+ +
+

4 UI/UX 设计建议

+ +

4.1 嵌入环境概览

+ + + + + + + +
宿主产品用户设备分辨率参考布局策略
eServicesInvestorDesktop Browser1280-1920px侧栏 / 全宽卡片式
ProXStaffDesktop1440-2560px全宽表格 + 侧栏筛选
OMCStaffDesktop1280-1920px全宽控制面板
+ +

4.2 投资者 Widget — 创建 PRP 计划页面

+ +
+
+
+
eServices — PRP 定期投资
+
+
+
创建 PRP 定期投资计划
+
+
+
Step 1: 选择标的
+
搜索股票代码... NVDA
+
+
+
标的资料 (Instrument Master)
+
NVIDIA Corp
+
+
+
+
+
每次金额
+
$ 1,000 USD
+
+
+
频率
+
+ + + +
+
+
+
执行日
+
周三 ▼
+
如遇非交易日,自动顺延至下一个交易日
+
+
+
+
+
开始日期 *
+
2026-08-03 (必须 > 今日)
+
+
+
结束日期
+
2027-08-03
+
+ + +
+
+
+
碎股处理
+
允许碎股 ▼
+
+
+
+
+ 预估: 52次/年 · 年投入 $52,000 · 费用约 $26/年 +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
UI 字段中英对照 (Bilingual Reference) — 创建 PRP 计划
选择标的Select Instrument
搜索股票代码...Search stock code...
标的资料 (Instrument Master)Instrument Master
每次金额Amount per Time
频率Frequency
每日 / 每周 / 每月Daily / Weekly / Monthly
执行日Execution Day
如遇非交易日,自动顺延至下一个交易日If non-trading day, deferred to next trading day
开始日期 *Start Date *
必须 > 今日Must be > today
结束日期End Date
Good Till Cancel (勾选后结束日期自动禁用)Good Till Cancel (Checking disables end date)
碎股处理Fractional Share Handling
允许碎股Allow Fractional Shares
确认创建Confirm Creation
预估Estimate
+ +

4.3 投资者 Widget — 计划列表页

+ +
+
+
+
我的 PRP 投资计划
+
+
+
+
标的
频率
累计投入
持仓市值 / 盈亏
PRP 总回报
+
+
+
NVDA NVIDIA Corp
+
每周三
+
$8,000
+
$9,240 +15.5%
+
+$1,240 (+15.5%)
+
进行中
+
+
+
QQQ Invesco QQQ
+
每周一
+
$12,500
+
$13,800 +10.4%
+
+$1,300 (+10.4%)
+
进行中
+
+
+
0700 Tencent
+
每月 1 号
+
HK$15,000
+
HK$13,200 -12.0%
+
+
已卖出部分
+ HK$-1,800 (-12%) +
+
已暂停
+
+
+
+ + + + + + + + + + + + + + + +
UI 字段中英对照 (Bilingual Reference) — 计划列表
我的 PRP 投资计划My PRP Investment Plans
标的Instrument
频率Frequency
累计投入Total Invested
持仓市值 / 盈亏Position Value / P&L
PRP 总回报PRP Total Return
每周三 / 每周一 / 每月 1 号Every Wednesday / Every Monday / 1st of month
已卖出部分Partially Sold
进行中Active
已暂停Paused
+ +

4.4 投资者 Widget — 修改 PRP 计划页面

+ +
+
+
+
修改 PRP 计划 — NVDA (NVIDIA Corp)
+
+
+
+ 计划编号: PRP-20260801-0001 · 创建于: 2026-07-15 · 已执行: 2次 +
+ +
+
+
标的代码(不可修改)
+
NVDA
+
+
+
标的���料 (Instrument Master)
+
NVIDIA Corp
+
+
+
+
+
每次金额
+
$ 2,000 USD (原: $1,000)
+
修改后下次执行生效
+
+
+
频率
+
+ + + +
+
+
+
碎股处理
+
向下取整 ▼ (原: 允许碎股)
+
+
+
+
+
修改原因
+
增加每月投资金额
+
全部修改将记录至审计日志
+
+
+ +
+
+ 修改后预估: 12次/年 · 年投入 $24,000 +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
UI 字段中英对照 (Bilingual Reference) — 修改 PRP 计划
修改 PRP 计划Modify PRP Plan
计划编号Plan ID
创建于Created
已执行Executed
标的代码(不可修改)Instrument Code (Non-modifiable)
标的资料 (Instrument Master)Instrument Master
每次金额Amount per Time
修改后下次执行生效Takes effect on next execution
频率Frequency
每日 / 每周 / 每月Daily / Weekly / Monthly
碎股处理Fractional Share Handling
向下取整 / 允许碎股Round Down / Allow Fractional
修改原因Reason for Modification
全部修改将记录至审计日志All modifications recorded in audit log
修改后预估Revised Estimate
取消Cancel
确认修改Confirm Modification
+ +

4.5 投资者 Widget — A5 投资预测

+ +
+
+
+
投资预测 — PRP 定期投资模拟
+
+
+
+
+
计划参数
+
NVDA · 每月 $1,000
+
30 年 · 360 次执行
+
+
+
预估年化回报率
+
+
12%
+ +
+
+
+ +
+
+
总投资额
+
$360,000
+
(30年 x $12,000/年)
+
+
+
预估组合市值
+
$3,520,000
+
9.8x 总投入
+
+
+
累计复利收益
+
+$3,160,000
+
+878%
+
+
+ +
+
Demostration — DCA vs Portfolio Value (30-Year Projection)
+ + + + + + + + + + + + + + + + + + + + + + + + + $0 + $0.5M + $1.0M + $1.5M + $2.0M + $2.5M + $3.0M + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 年份 + + + + + + + + + $3.52M + + + $360K + + 投资组合市值 (12% CAGR) + 累计投入 (Cost Basis) + + + + + + +
+
+
+ 累计投入 (30年共 $360K) +
+
+
+ 投资组合市值 (终值 $3.52M, 9.8x) +
+
+
+ +
+ 关键洞察:即使每笔只需 $1,000/月,经过 30 年纪律性定投 + 12% 年化复利,投资组合市值可达总投入的 9.8 倍。这正是 DCA 定投的核心价值 — 时间 + 复利 = 财富增长的最大杠杆。 +
+
+
+ + + + + + + + + + + + + + + +
UI 字段中英对照 (Bilingual Reference) — 投资预测
投资预测 — PRP 定期投资模拟Investment Forecast — PRP Recurring Investment Simulation
计划参数Plan Parameters
预估年化回报率Estimated Annualized Return
更新 / 计算Update / Calculate
总投资额Total Investment
预估组合市值Estimated Portfolio Value
累计复利收益Cumulative Compound Returns
投资组合市值 (12% CAGR)Portfolio Value (12% CAGR)
累计投入 (Cost Basis)Total Invested (Cost Basis)
关键洞察Key Insight
+ +

4.6 员工 Widget — 计划监控 (ProX / OMC)

+ +
+
+
+
ProX / OMC — PRP 计划监控面板
+
+
+
+
全部交易所 ▼
+
全部状态 ▼
+
搜索客户/代码...
+ + +
+
+ 活跃计划: 1,247 · 今日待执行: 83 · 异常: 2 +
+ + + + + + + +
计划编号客户ID标的金额频率状态操作
PRP-0001C12034NVDA$1,000每月ACTIVE
PRP-0042C087120700HK$5,000每周ACTIVE
PRP-0103C29105AAPL$500每日PAUSED
+
支持双击表格行快速进入修改模式,员工可代投资者修改计划参数。修改操作记录审计日志并标记 modified_by_role=STAFF。
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
UI 字段中英对照 (Bilingual Reference) — 员工计划监控
计划监控面板Plan Monitoring Dashboard
全部交易所All Exchanges
全部状态All Statuses
搜索客户/代码...Search client/code...
刷新Refresh
导出 CSVExport CSV
活跃计划Active Plans
今日待执行Today Pending
异常Anomalies
计划编号Plan ID
客户IDClient ID
标的Instrument
金额Amount
频率Frequency
状态Status
操作Actions
详情Details
代客修改Modify on Behalf
支持双击表格行快速进入修改模式...Double-click row to enter modification mode...
+ +

4.7 员工 Widget — B3 报表样本

+ +
+
+
+
OMC — PRP 月度报表 (2026年7月)
+
+
+
+
2026 年 7 月
+
全部交易所 ▼
+ + +
+ +
+
+
活跃计划数
+
1,247
+
+38 vs 上月
+
+
+
月执行次数
+
18,962
+
成功率 99.2%
+
+
+
月总投入金额
+
US$8.3M
+
折合 HK$64.7M / CNY59.6M
+
+
+ + + + + + + + +
交易所活跃计划执行次数成功失败总金额平均每笔
US85213,44013,35090$6.2M$464
HK2984,3124,28824HK$16.4MHK$3,820
CN971,2101,19614CNY5.8MCNY4,849
+ +
+ 失败清单 (TOP 3): 资金不足 (62) · 停牌 (38) · 系统超时 (28) +
+
+
+ + + + + + + + + + + + + + + + + +
UI 字段中英对照 (Bilingual Reference) — 月度报表
月度报表Monthly Report
导出 PDFExport PDF
导出 ExcelExport Excel
活跃计划数Active Plans
月执行次数Monthly Executions
月总投入金额Monthly Total Investment
交易所Exchange
成功Success
失败Failed
总金额Total Amount
平均每笔Avg per Trade
失败清单 (TOP 3)Failure List (TOP 3)
+ +

4.8 设计风格

+
+ 说明:PRP Widget 的视觉风格(配色、字体、间距、组件样式)应与嵌入的宿主产品(eServices / ProX / OMC)保持一致,由开发团队按各产品现有 Design System 实现。本报告中的 Wireframe 仅供布局参考。 +
+
+ +
+

5 技术架构 & 集成方案

+ +

5.1 N2N-AFE 系统集成架构

+ +
+

展示层 — Embedded Widget

+

投资者端 (eServices) + 员工端 (ProX / OMC) — 同一组件,通过 G3SF API 通信。

+
+ +
▼
+ +
+

G3SF — 前台交易系统

+

+ 存储: 计划数据 (plans)、执行记录 (executions)、审计日志 (modifications)
+ 调度: 08:00 HKT(亚洲市场)/ 20:00 HKT(美股市场)定时扫描待执行计划
+ 订单生成: 根据计划参数 + 市场规则计算股数、生成订单发送至券商 OMS
+ API: RESTful API 供 Widget 调用 +

+
+ +
▼
+ +
+

G3SB — 后台结算系统

+

+ PRP 成交数据基于现有 G3SB 与券商 OMS 之间的数据交换机制处理,无需新增接口。成交回报中标记 trade_remark 为 PRP 投资计划,G3SB 据此更新投资者持仓并将执行结果回写 execution 记录。 +

+
+ +
▼
+ +
+

数据层 — 使用现有数据库

+

+ PRP 数据表 (plans / plan_executions / plan_modifications) 存放于现有 G3SF 数据库实例中,无需新建独立数据库。通过 g3sf_order_id 与订单系统关联追踪。 +

+
+ +

5.2 数据流详解

+ +
+
1
+
+
投资者创建 PRP 计划 → Widget 调用 G3SF API
+
POST /api/prp/plans → G3SF 校验参数 → 存入 plans 表 → 返回 plan_id
+
+
+
+
2
+
+
Scheduler 定时扫描 → 生成执行记录
+
08:00 / 20:00 HKT → 查询 ACTIVE 计划,判断今天是否为执行日 → INSERT INTO plan_executions (PENDING)
+
+
+
+
3
+
+
计算股数 → 生成订单 → 发送至券商 OMS
+
G3SF 根据 fractional_handling 计算 quantity → 创建 G3SF 订单(trade_remark="PRP") → 路由至券商 OMS
+
+
+
+
4
+
+
OMS 成交 → G3SB 结算 → 更新持仓
+
基于现有 G3SB-OMS 数据交换 → 结算处理 → 更新投资者持仓 → 回写 execution(EXECUTED, price, fees)
+
+
+
+
5
+
+
更新计划累计统计 + 发送邮件通知
+
更新 plans(total_invested / total_units / avg_cost / execution_count) → 发送执行成功邮件给投资者
+
+
+ + +

5.3 错误处理与重试机制

+ +
+
5.3.1 资金不足处理 (SHORTFUND)
+
    +
  • 系统在生成订单前,通过 G3SF 尝试预留/冻结购买所需资金(金额 + 预估费用)
  • +
  • 若账户可用资金不足 → 执行记录标记为 REJECTED(error_code=SHORTFUND)
  • +
  • 发送邮件通知客户:PRP 执行失败 — 账户资金不足,邮件包含:计划详情、所需金额、账户可用余额
  • +
  • 客户需自行手动补购该期份额(系统不追溯补单)
  • +
  • 系统在下一执行周期自动重新触发(按正常计划参数执行,不累积失败期数)
  • +
+
+ +
+
5.3.2 标的停牌处理 (SUSPENDED)
+
    +
  • 执行日扫描时,校验标的交易状态
  • +
  • 若标的处于停牌状态 → 执行记录标记为 REJECTED(error_code=SUSPENDED)
  • +
  • 发送邮件通知客户:PRP 执行失败 — 标的已停牌
  • +
  • 该笔不补单,系统在下一执行周期按正常流程重新尝试
  • +
+
+ +
+
5.3.3 无效订单��格重试 (PRICE_REJECTED)
+
    +
  • 若订单提交至交易所后因无效价格(如超出涨跌停范围、报价单位不符等)被拒绝
  • +
  • 系统自动重试,重试次数 X 由系统配置(默认 3 次),每次间隔 Y 秒(默认 30 秒)
  • +
  • 每次重试时重新获取最新市价并调整订单价格
  • +
  • 重试 X 次后仍全部失败 → 执行记录标记为 FAILED(error_code=PRICE_REJECTED)
  • +
  • 发送邮件通知客户:PRP 执行失败 — 订单价格无效,已重试 X 次
  • +
+
+ +

5.3.4 拒绝邮件清单

+ + + + + + + + +
error_code邮件主题触发条件后续动作
SHORTFUNDPRP 执行失败 — 账户资金不足预扣资金时账户余额不足客户手动补购;下周期自动重试
SUSPENDEDPRP 执行失败 — 标的已停牌执行日标的处于停牌状态下周期自动重试
PRICE_REJECTEDPRP 执行失败 — 订单价格无效交易所拒单,重试 X 次后仍失败标记 FAILED,不再重试
SYSTEM_ERRORPRP 执行失败 — 系统异常G3SF/G3SB 内部错误或超时员工人工介入
+ +

5.3.5 系统配置参数

+
+execution: + # 价格重试 + price_retry_count: 3 # 无效价格最大重试次数 + price_retry_interval_sec: 30 # 每次重试间隔(秒) + + # 资金预扣 + prehold_fund_check: true # 执行前是否预扣资金检查 + prehold_buffer_pct: 5 # 预扣金额缓冲百分比(覆盖费用波动) + + # 交易状态检查 + check_trading_status: true # 执行前检查标的是否可交易 +
+ +

5.4 API 接口规范(建议)

+ + + + + + + + + + + + + +
方法路径说明调用方
POST/api/v1/prp/plans创建 PRP 计划Investor Widget
GET/api/v1/prp/plans?client_id=...查询客户计划列表Investor Widget
GET/api/v1/prp/plans/{plan_id}计划详情Investor & Staff Widget
PUT/api/v1/prp/plans/{plan_id}修改计划Investor Widget
PATCH/api/v1/prp/plans/{plan_id}/status暂停/恢复/取消Investor & Staff Widget
GET/api/v1/prp/plans/{plan_id}/executions执行历史Investor Widget
GET/api/v1/prp/staff/executions/today今日执行清单Staff Widget
GET/api/v1/prp/staff/plans?status=...&exchange=...员工计划总览Staff Widget
GET/api/v1/prp/staff/reports/monthly月度报表Staff Widget
+
+ +
+ N2N-AFE — PRP (Periodic Recurring Program) System Design v1.3
+ Prepared by Jagon Li · 2026-08-03 · Internal Document +
+ +
+ + \ No newline at end of file diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..fba1a55 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/api/base.ts b/src/api/base.ts new file mode 100644 index 0000000..d4c40de --- /dev/null +++ b/src/api/base.ts @@ -0,0 +1,15 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' + +export interface SystemI18n { + id: string + scDescription: string + tcDescription: string + enDescription: string + vnDescription?: string + remark?: string +} + +export function getSystemI18nAllList(): Promise> { + return http.post('/base/systemI18n/getAllList', {}) +} diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..ed71e2c --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,89 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useAppStore } from '@/stores/app' +import { useAuthStore } from '@/stores/auth' +import { getToken, removeToken } from '@/utils/token' + +function resolveBaseURL(): string { + const configured = import.meta.env.VITE_API_BASE_URL?.trim() + if (configured) { + return configured.replace(/\/+$/, '') + } + return '' +} + +const http = axios.create({ + baseURL: resolveBaseURL(), + timeout: 15000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +http.interceptors.request.use( + (config) => { + const token = getToken() + if (token) { + config.headers.token = token + } + try { + const appStore = useAppStore() + config.headers['Accept-Language'] = appStore.locale + } catch { + config.headers['Accept-Language'] = 'zh-TW' + } + try { + const authStore = useAuthStore() + const { clientId, accountId } = authStore + if (clientId || accountId) { + const base = + config.data !== undefined && + typeof config.data === 'object' && + config.data !== null && + !(config.data instanceof FormData) + ? { ...(config.data as Record) } + : {} + if (clientId) { + base.clientId = clientId + } + if (accountId) { + base.accountId = accountId + } + config.data = base + } + } catch { + // pinia 未就绪时跳过 + } + return config + }, + (error) => Promise.reject(error), +) + +http.interceptors.response.use( + (response) => { + const { data } = response + if (data?.code === '0' || data?.code === 0) { + return data + } + if (data?.code === '401' || data?.code === 401 || data?.code === '403' || data?.code === 403) { + removeToken() + ElMessage.error(data?.message || '登录已过期,请重新登录') + return Promise.reject(new Error(data?.message || 'Unauthorized')) + } + if (data?.message) { + ElMessage.error(data.message) + } + return Promise.reject(new Error(data?.message || '请求失败')) + }, + (error) => { + if (error.response?.status === 401 || error.response?.status === 403) { + removeToken() + ElMessage.error('登录已过期,请重新登录') + } else if (error.response?.data?.message) { + ElMessage.error(error.response.data.message) + } + return Promise.reject(error) + }, +) + +export default http diff --git a/src/api/product.ts b/src/api/product.ts new file mode 100644 index 0000000..217ae3d --- /dev/null +++ b/src/api/product.ts @@ -0,0 +1,50 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' + +/** `getSimpleStockPage` 单条标的 */ +export interface SimpleStockItem { + engName: string + exchangeCode: string + fractionalBase: number | null + isin: string + lastActiveDate: string + localName: string + productCode: string + productCodeUnique: string + productKey: string + productType: string +} + +export interface SimpleStockPageRequest { + current: number + size: number + searchName: string + exchangeCodes: string[] + isUpdatedToday: boolean +} + +export interface SimpleStockPageData { + records: SimpleStockItem[] + total?: number + current?: number + size?: number +} + +/** 简易股票分页(定期投资资产搜索) */ +export function getSimpleStockPage( + params: SimpleStockPageRequest, +): Promise> { + return http.post('/product/product/getSimpleStockPage', params) +} + +export function extractSimpleStockRecords( + data: SimpleStockPageData | SimpleStockItem[] | null | undefined, +): SimpleStockItem[] { + if (!data) { + return [] + } + if (Array.isArray(data)) { + return data + } + return Array.isArray(data.records) ? data.records : [] +} diff --git a/src/api/scheduleInvestment.ts b/src/api/scheduleInvestment.ts new file mode 100644 index 0000000..a2baba6 --- /dev/null +++ b/src/api/scheduleInvestment.ts @@ -0,0 +1,70 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' +import type { + ScheduleInvestmentCreateBody, + ScheduleInvestmentExchangeItem, + ScheduleInvestmentPlanDetailData, + ScheduleInvestmentPlanDetailRequest, + ScheduleInvestmentPlanPageData, + ScheduleInvestmentNextExecutionData, + ScheduleInvestmentNextExecutionRequest, + ScheduleInvestmentPlanPageRequest, + ScheduleInvestmentPlanTerminateRequest, +} from '@/types/scheduleInvestment' + +/** 定期投资可选交易所列表 */ +export function getScheduleInvestmentExchanges(): Promise< + ApiResponse +> { + return http.post('/trade/schedule-investment/exchanges', {}) +} + +/** 创建定期投资计划 */ +export function createScheduleInvestmentPlan( + body: ScheduleInvestmentCreateBody, +): Promise> { + return http.post('/trade/schedule-investment/plan/create', body) +} + +const EMPTY_PLAN_PAGE_FILTERS = { + accountCode: '', + exchangeCode: '', + productCode: '', + status: '', + investmentType: '', + orderPriceType: '', + startDate: '', + endDate: '', +} as const + +/** 定期投资计划分页列表 */ +export function getScheduleInvestmentPlanPage( + params: Pick & + Partial>, +): Promise> { + return http.post('/trade/schedule-investment/plan/page', { + ...EMPTY_PLAN_PAGE_FILTERS, + ...params, + }) +} + +/** 定期投资计划详情(含最近执行记录) */ +export function getScheduleInvestmentPlanDetail( + body: ScheduleInvestmentPlanDetailRequest, +): Promise> { + return http.post('/trade/schedule-investment/plan/detail', body) +} + +/** 终止定期投资计划 */ +export function terminateScheduleInvestmentPlan( + body: ScheduleInvestmentPlanTerminateRequest, +): Promise> { + return http.post('/trade/schedule-investment/plan/terminate', body) +} + +/** 预览下次计划执行(节假日调整后的执行日与时点) */ +export function previewScheduleInvestmentNextExecution( + body: ScheduleInvestmentNextExecutionRequest, +): Promise> { + return http.post('/trade/schedule-investment/plan/next-execution', body) +} diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/components/layout/AppPage.vue b/src/components/layout/AppPage.vue new file mode 100644 index 0000000..dd97491 --- /dev/null +++ b/src/components/layout/AppPage.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/src/components/recurring/AssetChipPicker.vue b/src/components/recurring/AssetChipPicker.vue new file mode 100644 index 0000000..0c57f7b --- /dev/null +++ b/src/components/recurring/AssetChipPicker.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/src/composables/useAccountContext.ts b/src/composables/useAccountContext.ts new file mode 100644 index 0000000..9e30be2 --- /dev/null +++ b/src/composables/useAccountContext.ts @@ -0,0 +1,17 @@ +import { computed } from 'vue' +import { useAuthStore } from '@/stores/auth' + +/** 当前 URL 中的 clientId / accountId(路由跳转经 useRouteWithPreferences 会保留) */ +export function useAccountContext() { + const authStore = useAuthStore() + + const clientId = computed(() => authStore.clientId) + const accountId = computed(() => authStore.accountId) + const hasAccountContext = computed(() => !!authStore.clientId && !!authStore.accountId) + + return { + clientId, + accountId, + hasAccountContext, + } +} diff --git a/src/composables/useBreakpoint.ts b/src/composables/useBreakpoint.ts new file mode 100644 index 0000000..3730324 --- /dev/null +++ b/src/composables/useBreakpoint.ts @@ -0,0 +1,38 @@ +import { BREAKPOINTS_PX } from '@/constants/breakpoints' +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' + +function readWidth() { + return window.innerWidth +} + +export function useBreakpoint() { + const width = ref(readWidth()) + + const update = () => { + width.value = readWidth() + } + + onMounted(() => { + window.addEventListener('resize', update, { passive: true }) + update() + }) + + onBeforeUnmount(() => { + window.removeEventListener('resize', update) + }) + + const isMobile = computed(() => width.value < BREAKPOINTS_PX.md) + const isTablet = computed( + () => width.value >= BREAKPOINTS_PX.md && width.value < BREAKPOINTS_PX.lg, + ) + const isDesktop = computed(() => width.value >= BREAKPOINTS_PX.lg) + const isWide = computed(() => width.value >= BREAKPOINTS_PX.xl) + + return { + width, + isMobile, + isTablet, + isDesktop, + isWide, + } +} diff --git a/src/composables/useRouteWithPreferences.ts b/src/composables/useRouteWithPreferences.ts new file mode 100644 index 0000000..299c8a1 --- /dev/null +++ b/src/composables/useRouteWithPreferences.ts @@ -0,0 +1,12 @@ +import { useRoute } from 'vue-router' + +/** 路由跳转时保留当前 URL 中的 lang / theme 等查询参数 */ +export function useRouteWithPreferences() { + const route = useRoute() + + function linkTo(path: string) { + return { path, query: route.query } + } + + return { linkTo } +} diff --git a/src/composables/useServiceI18n.ts b/src/composables/useServiceI18n.ts new file mode 100644 index 0000000..10836fe --- /dev/null +++ b/src/composables/useServiceI18n.ts @@ -0,0 +1,17 @@ +import { useAppStore } from '@/stores/app' +import { useServiceI18nStore } from '@/stores/serviceI18n' + +/** 服务端多语言 ID 解析(参考 Order-H5 localeStore.getI18n) */ +export function useServiceI18n() { + const appStore = useAppStore() + const serviceI18nStore = useServiceI18nStore() + + function si18n(id: string | undefined) { + return serviceI18nStore.getText(id, appStore.locale) + } + + return { + si18n, + reloadServiceI18n: serviceI18nStore.reload, + } +} diff --git a/src/composables/useUrlPreferences.ts b/src/composables/useUrlPreferences.ts new file mode 100644 index 0000000..8362f68 --- /dev/null +++ b/src/composables/useUrlPreferences.ts @@ -0,0 +1,26 @@ +import { useRoute, useRouter } from 'vue-router' +import type { AppPreferences } from '@/config/urlPreferences' +import type { AppLocale } from '@/i18n/locale' +import { useAppStore } from '@/stores/app' +import type { AppTheme } from '@/theme/types' + +export function useUrlPreferences() { + const route = useRoute() + const router = useRouter() + const appStore = useAppStore() + + function replacePreferences(partial: Partial) { + const query = appStore.buildQueryForPreferences(partial, route.query) + router.replace({ path: route.path, hash: route.hash, query }) + } + + function setLocale(next: AppLocale) { + replacePreferences({ locale: next }) + } + + function setTheme(next: AppTheme) { + replacePreferences({ theme: next }) + } + + return { setLocale, setTheme } +} diff --git a/src/config/appLocale.ts b/src/config/appLocale.ts new file mode 100644 index 0000000..3b1e7a7 --- /dev/null +++ b/src/config/appLocale.ts @@ -0,0 +1,9 @@ +export const APP_LOCALES = ['zh-CN', 'zh-TW', 'en-US'] as const + +export type AppLocale = (typeof APP_LOCALES)[number] + +export const DEFAULT_LOCALE: AppLocale = 'zh-TW' + +export function isAppLocale(value: string | null): value is AppLocale { + return value !== null && (APP_LOCALES as readonly string[]).includes(value) +} diff --git a/src/config/urlContext.ts b/src/config/urlContext.ts new file mode 100644 index 0000000..feb80f7 --- /dev/null +++ b/src/config/urlContext.ts @@ -0,0 +1,41 @@ +import type { LocationQuery } from 'vue-router' + +export const URL_PARAM_CLIENT_ID = 'clientId' +export const URL_PARAM_ACCOUNT_ID = 'accountId' + +export interface UrlAccountContext { + clientId: string | null + accountId: string | null +} + +function readQueryValue(query: LocationQuery, key: string): string | null { + const raw = query[key] + if (raw === undefined || raw === null) { + return null + } + const value = Array.isArray(raw) ? raw[0] : raw + if (value === undefined || value === null || value === '') { + return null + } + return value +} + +function readSearchParam(search: string, key: string): string | null { + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + const value = params.get(key) + return value && value.trim() !== '' ? value : null +} + +export function parseAccountContextFromSearch(search: string): UrlAccountContext { + return { + clientId: readSearchParam(search, URL_PARAM_CLIENT_ID), + accountId: readSearchParam(search, URL_PARAM_ACCOUNT_ID), + } +} + +export function parseAccountContextFromQuery(query: LocationQuery): UrlAccountContext { + return { + clientId: readQueryValue(query, URL_PARAM_CLIENT_ID), + accountId: readQueryValue(query, URL_PARAM_ACCOUNT_ID), + } +} diff --git a/src/config/urlPreferences.ts b/src/config/urlPreferences.ts new file mode 100644 index 0000000..9a67dea --- /dev/null +++ b/src/config/urlPreferences.ts @@ -0,0 +1,88 @@ +import type { LocationQuery } from 'vue-router' +import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale' +import { APP_THEMES, DEFAULT_THEME, type AppTheme } from '@/theme/types' + +export const URL_PARAM_LANG = 'lang' +export const URL_PARAM_THEME = 'theme' + +export interface AppPreferences { + locale: AppLocale + theme: AppTheme +} + +function readQueryValue(query: LocationQuery, key: string): string | undefined { + const raw = query[key] + if (raw === undefined || raw === null) { + return undefined + } + const value = Array.isArray(raw) ? raw[0] : raw + if (value === undefined || value === null || value === '') { + return undefined + } + return value +} + +function normalizeTheme(value: string | undefined): AppTheme { + if (!value) { + return DEFAULT_THEME + } + const lower = value.toLowerCase() + if (lower === 'day' || lower === 'light') { + return 'light' + } + if (lower === 'night' || lower === 'dark') { + return 'dark' + } + return APP_THEMES.includes(lower as AppTheme) ? (lower as AppTheme) : DEFAULT_THEME +} + +function normalizeLocale(value: string | undefined): AppLocale { + if (!value) { + return DEFAULT_LOCALE + } + const normalized = value.replace('_', '-') + if (isAppLocale(normalized)) { + return normalized + } + return DEFAULT_LOCALE +} + +export function parsePreferencesFromSearch(search: string): AppPreferences { + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + const lang = params.get(URL_PARAM_LANG) ?? params.get('locale') + const theme = params.get(URL_PARAM_THEME) + return { + locale: normalizeLocale(lang ?? undefined), + theme: normalizeTheme(theme ?? undefined), + } +} + +export function parsePreferencesFromQuery(query: LocationQuery): AppPreferences { + const lang = readQueryValue(query, URL_PARAM_LANG) ?? readQueryValue(query, 'locale') + const theme = readQueryValue(query, URL_PARAM_THEME) + return { + locale: normalizeLocale(lang), + theme: normalizeTheme(theme), + } +} + +export function preferencesToQuery( + preferences: AppPreferences, + existing: LocationQuery = {}, +): Record { + const next: Record = {} + for (const [key, value] of Object.entries(existing)) { + if (key === URL_PARAM_LANG || key === 'locale' || key === URL_PARAM_THEME) { + continue + } + if (value !== undefined && value !== null) { + const v = Array.isArray(value) ? value[0] : value + if (v !== undefined && v !== null && v !== '') { + next[key] = v + } + } + } + next[URL_PARAM_LANG] = preferences.locale + next[URL_PARAM_THEME] = preferences.theme + return next +} diff --git a/src/constants/breakpoints.ts b/src/constants/breakpoints.ts new file mode 100644 index 0000000..8f8c50a --- /dev/null +++ b/src/constants/breakpoints.ts @@ -0,0 +1,20 @@ +/** 与 `src/styles/breakpoints.scss` 保持数值一致 */ +export const BREAKPOINTS_PX = { + sm: 576, + md: 768, + lg: 1024, + xl: 1280, +} as const + +export type BreakpointToken = keyof typeof BREAKPOINTS_PX + +export const LAYOUT_MAX_WIDTH_PX = 1280 + +/** 小于 md 视为移动端 */ +export const MOBILE_MAX_PX = BREAKPOINTS_PX.md - 1 + +/** 平板:md ≤ width < lg */ +export const TABLET_MIN_PX = BREAKPOINTS_PX.md +export const TABLET_MAX_PX = BREAKPOINTS_PX.lg - 1 + +export const DESKTOP_MIN_PX = BREAKPOINTS_PX.lg diff --git a/src/element-plus/locale.ts b/src/element-plus/locale.ts new file mode 100644 index 0000000..7a31c4e --- /dev/null +++ b/src/element-plus/locale.ts @@ -0,0 +1,15 @@ +import type { Language } from 'element-plus/es/locale' +import en from 'element-plus/es/locale/lang/en' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import zhTw from 'element-plus/es/locale/lang/zh-tw' +import type { AppLocale } from '@/config/appLocale' + +const elementPlusLocaleMap: Record = { + 'zh-CN': zhCn, + 'zh-TW': zhTw, + 'en-US': en, +} + +export function getElementPlusLocale(locale: AppLocale): Language { + return elementPlusLocaleMap[locale] +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..8851d27 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string + readonly VITE_API_PROXY_TARGET?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..89d507b --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,18 @@ +import { createI18n } from 'vue-i18n' +import { DEFAULT_LOCALE, resolveInitialLocale } from './locale' +import enUS from './locales/en-US' +import zhCN from './locales/zh-CN' +import zhTW from './locales/zh-TW' + +const i18n = createI18n({ + legacy: false, + locale: resolveInitialLocale(), + fallbackLocale: DEFAULT_LOCALE, + messages: { + 'zh-CN': zhCN, + 'zh-TW': zhTW, + 'en-US': enUS, + }, +}) + +export default i18n diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts new file mode 100644 index 0000000..c2748c2 --- /dev/null +++ b/src/i18n/locale.ts @@ -0,0 +1,7 @@ +export { APP_LOCALES, DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale' +import { parsePreferencesFromSearch } from '@/config/urlPreferences' + +/** 首屏 i18n 初始化:仅从 URL 读取,缺省为繁体 */ +export function resolveInitialLocale() { + return parsePreferencesFromSearch(window.location.search).locale +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts new file mode 100644 index 0000000..f9d7eac --- /dev/null +++ b/src/i18n/locales/en-US.ts @@ -0,0 +1,170 @@ +export default { + common: { + appName: 'Schedule Invest Plan', + language: 'Language', + theme: 'Theme', + themeLight: 'Light', + themeDark: 'Dark', + }, + recurring: { + title: 'Recurring Investment', + lead: 'Set up a recurring investment plan. The system will place buy orders automatically at the interval you choose.', + myPlans: 'My recurring plans', + runningCount: '{n} active', + planEmpty: 'No recurring investment plans yet', + colAsset: 'Asset', + colAmount: 'Amount / qty', + colInterval: 'Interval', + colStart: 'Start date', + colEnd: 'End date', + colStatus: 'Status', + colAction: 'Action', + cancel: 'Stop', + cancelConfirmTitle: 'Stop this recurring investment plan?', + cancelConfirmOk: 'Confirm stop', + cancelConfirmBack: 'Cancel', + terminateDialogTitle: 'Stop recurring plan', + terminateDialogLead: 'No further buys will be scheduled. Past executions are not affected.', + terminateReasonLabel: 'Reason (optional)', + terminateReasonPlaceholder: 'Enter a reason if you wish', + terminateReasonHint: 'You may leave this blank and confirm.', + planDetail: { + detail: 'Details', + title: 'Plan details', + planNo: 'Plan no.', + exchange: 'Exchange', + product: 'Product', + investment: 'Per execution', + orderPrice: 'Order price', + frequency: 'Schedule', + ctOffset: 'Market open offset', + startDate: 'Start date', + endCondition: 'End condition', + nextExecution: 'Next execution', + progress: 'Progress', + progressValue: '{done} of {total} executed', + progressOpen: '{done} executed', + status: 'Status', + createdAt: 'Created at', + channel: 'Channel', + recentExecutions: 'Recent executions', + executionEmpty: 'No execution records', + colScheduledDate: 'Scheduled date', + colActualDate: 'Trade date', + colExecutionStatus: 'Execution', + colOrderStatus: 'Order', + colAmount: 'Amount / qty', + missingPlanNo: 'Plan number is missing', + }, + createTitle: 'Create recurring investment', + createHint: 'Stocks and ETFs', + exchange: 'Exchange', + asset: 'Asset', + assetPlaceholder: 'Search by code', + assetSupport: 'Supported: stocks, ETFs', + assetEmpty: 'No matching assets', + assetLoading: 'Loading…', + removeAsset: 'Remove {code}', + startDate: 'Start date', + amount: 'Amount', + amountHint: 'Amount to invest on each execution (HKD)', + quantity: 'Quantity', + amountPlaceholder: 'e.g. 10,000', + quantityPlaceholder: 'e.g. 100', + quantityHint: 'Shares to buy on each execution', + investmentTypeLabel: 'Investment type', + investmentType: { + AMOUNT: 'By amount', + QUANTITY: 'By quantity', + }, + orderPriceTypeLabel: 'Order price type', + orderPriceType: { + MARKET: 'Market', + LIMIT: 'Limit', + }, + limitPrice: 'Limit price', + limitPricePlaceholder: 'e.g. 350.5', + limitPriceHint: 'Required for limit orders; must be greater than 0', + frequencyLabel: 'Frequency', + frequency: { + DAILY: 'Daily', + WEEKLY: 'Weekly', + MONTHLY: 'Monthly', + }, + frequencyDayLabel: 'Execution day', + frequencyDayWeeklyHint: 'Fixed weekday each week', + frequencyDayMonthlyHint: 'Fixed calendar day each month (1–31)', + monthDayOption: 'Day {day} of month', + weekday: { + MON: 'Monday', + TUE: 'Tuesday', + WED: 'Wednesday', + THU: 'Thursday', + FRI: 'Friday', + SAT: 'Saturday', + SUN: 'Sunday', + }, + ctOffsetLabel: 'Trading time offset (minutes)', + ctOffsetHint: '0–240 minutes relative to market open', + ctOffsetPreview: '{n} min', + previewInvestmentLabel: 'Each investment', + previewQuantity: '{n} shares', + intervalLabel: 'Interval', + pleaseSelect: 'Please select', + endModeLabel: 'End type', + endByDate: 'Specific date', + endByCount: 'Periods', + endDate: 'End date', + noEndDate: 'No end date', + endCount: 'Periods', + endCountPlaceholder: 'e.g. 12', + endCountHint: 'The plan stops automatically after the specified number of executions', + endCountValue: '{n} periods', + notesTitle: 'Notes', + notes: { + n1: 'Recurring orders are placed as market orders at the open on each execution date; fills follow actual trades.', + n2: 'Fractional shares are not supported. Quantity is rounded down to whole shares, which may leave residual cash.', + n3: 'If the execution date is a non-trading day, the order is deferred to the next trading day.', + n4: 'If available funds are insufficient, that period’s order will not be executed; the plan continues.', + n5: 'You may stop the plan at any time. Executed buys cannot be cancelled.', + }, + reset: 'Reset', + previewNextExecution: 'Preview next run', + previewNextExecutionTitle: 'Next execution preview', + previewNextExecutionFill: 'Select exchange, frequency, and CT offset first', + previewScheduledDate: 'Scheduled date', + previewExecutionDate: 'Execution date', + previewExecutionDateTime: 'Execution time', + previewDeferred: 'Deferred from holiday', + previewDeferredYes: 'Yes', + previewDeferredNo: 'No', + continue: 'Continue', + confirmTitle: 'Confirm recurring plan', + eachAmount: 'Amount each time', + endPreview: 'End', + confirmNote: 'Once created, funds will be deducted and shares bought on each execution date. Please keep sufficient available balance.', + back: 'Back', + confirmCreate: 'Confirm', + fillRequired: 'Please complete all required fields', + createdToast: 'Recurring plan created', + cancelledToast: 'Plan stopped. Executed buys are not affected', + interval: { + daily: 'Daily', + weekly: 'Weekly', + biweekly: 'Biweekly', + monthly: 'Monthly', + quarterly: 'Quarterly', + semiannual: 'Semiannual', + annual: 'Annual', + }, + status: { + active: 'Active', + created: 'Created', + cancelled: 'Stopped', + }, + assetType: { + etf: 'ETF', + stock: 'Stock', + }, + }, +} diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..84c7a12 --- /dev/null +++ b/src/i18n/locales/zh-CN.ts @@ -0,0 +1,170 @@ +export default { + common: { + appName: '投资计划排期', + language: '语言', + theme: '主题', + themeLight: '浅色', + themeDark: '深色', + }, + recurring: { + title: '定期投资', + lead: '设定定期定额投资计划,系统将按设定间隔自动执行买入指示。', + myPlans: '我的定期投资计划', + runningCount: '{n} 项进行中', + planEmpty: '暂无定期投资计划', + colAsset: '资产', + colAmount: '金额/数量', + colInterval: '间隔', + colStart: '开始日期', + colEnd: '结束日期', + colStatus: '状态', + colAction: '操作', + cancel: '终止', + cancelConfirmTitle: '确定要终止此定期投资计划?', + cancelConfirmOk: '确认终止', + cancelConfirmBack: '取消', + terminateDialogTitle: '终止定期投资计划', + terminateDialogLead: '终止后将不再执行新的买入指示,已执行之买入不受影响。', + terminateReasonLabel: '终止原因(选填)', + terminateReasonPlaceholder: '可填写终止原因', + terminateReasonHint: '可不填,直接确认终止。', + planDetail: { + detail: '详情', + title: '计划详情', + planNo: '计划编号', + exchange: '交易所', + product: '产品', + investment: '每次投资', + orderPrice: '订单价格', + frequency: '定投周期', + ctOffset: '交易时间偏移', + startDate: '开始日期', + endCondition: '结束条件', + nextExecution: '下次执行日', + progress: '执行进度', + progressValue: '已执行 {done} / 共 {total} 期', + progressOpen: '已执行 {done} 期', + status: '状态', + createdAt: '创建时间', + channel: '渠道', + recentExecutions: '最近执行', + executionEmpty: '暂无执行记录', + colScheduledDate: '计划执行日', + colActualDate: '实际交易日', + colExecutionStatus: '执行状态', + colOrderStatus: '委托状态', + colAmount: '金额/数量', + missingPlanNo: '缺少计划编号,无法查询详情', + }, + createTitle: '创建定期投资', + createHint: '支持股票及 ETF', + exchange: '交易所', + asset: '资产', + assetPlaceholder: '输入代号搜索', + assetSupport: '支持的: 股票、ETF', + assetEmpty: '没有匹配的资产', + assetLoading: '加载中…', + removeAsset: '移除 {code}', + startDate: '开始日期', + amount: '金额', + amountHint: '每次执行买入之金额(HKD)', + quantity: '数量', + amountPlaceholder: '例如 10,000', + quantityPlaceholder: '例如 100', + quantityHint: '每次执行买入之股数', + investmentTypeLabel: '投资类型', + investmentType: { + AMOUNT: '按金额', + QUANTITY: '按数量', + }, + orderPriceTypeLabel: '订单价格类型', + orderPriceType: { + MARKET: '市价', + LIMIT: '限价', + }, + limitPrice: '限价', + limitPricePlaceholder: '例如 350.5', + limitPriceHint: '限价单须填写大于 0 的价格', + frequencyLabel: '定投周期', + frequency: { + DAILY: '每日', + WEEKLY: '每周', + MONTHLY: '每月', + }, + frequencyDayLabel: '执行日', + frequencyDayWeeklyHint: '每周固定星期执行', + frequencyDayMonthlyHint: '每月固定日期执行(1–31)', + monthDayOption: '每月 {day} 日', + weekday: { + MON: '星期一', + TUE: '星期二', + WED: '星期三', + THU: '星期四', + FRI: '星期五', + SAT: '星期六', + SUN: '星期日', + }, + ctOffsetLabel: '交易时间偏移(分钟)', + ctOffsetHint: '范围 0–240,相对开市时间的偏移', + ctOffsetPreview: '{n} 分钟', + previewInvestmentLabel: '每次投资', + previewQuantity: '{n} 股', + intervalLabel: '间隔', + pleaseSelect: '请选择', + endModeLabel: '结束类型', + endByDate: '特定日期', + endByCount: '期数', + endDate: '结束日期', + noEndDate: '无结束日期', + endCount: '期数', + endCountPlaceholder: '例如 12', + endCountHint: '计划执行满指定期数后自动终止', + endCountValue: '期数 {n} 期', + notesTitle: '注意事项', + notes: { + n1: '定期投资指示于执行日开市时以市价单执行,成交价以实际成交为准。', + n2: '本功能不支持不足一股的股份。定期投资的买入股数将向下取整至整股,因此可能产生碎股(零股)。', + n3: '如执行日为非交易日,将顺延至下一个交易日执行。', + n4: '账户可用资金不足时,该期指示将不获执行,计划仍会继续。', + n5: '计划可随时终止,已执行之买入不可撤回。', + }, + reset: '重设', + previewNextExecution: '预览下次执行', + previewNextExecutionTitle: '下次执行预览', + previewNextExecutionFill: '请先选择交易所、定投周期与时间偏移', + previewScheduledDate: '周期日期', + previewExecutionDate: '执行日期', + previewExecutionDateTime: '执行时间', + previewDeferred: '非交易日顺延', + previewDeferredYes: '是', + previewDeferredNo: '否', + continue: '继续', + confirmTitle: '确认定期投资计划', + eachAmount: '每次金额', + endPreview: '结束', + confirmNote: '计划建立后将于每个执行日自动扣除资金并买入,请确保账户有足够可用结余。', + back: '返回', + confirmCreate: '确认建立', + fillRequired: '请填妥所有必填栏位', + createdToast: '定期投资计划已建立', + cancelledToast: '计划已终止,已执行之买入不受影响', + interval: { + daily: '每日', + weekly: '每周', + biweekly: '每两周', + monthly: '月度', + quarterly: '季度', + semiannual: '每半年', + annual: '每年', + }, + status: { + active: '进行中', + created: '已建立', + cancelled: '已终止', + }, + assetType: { + etf: 'ETF', + stock: '股票', + }, + }, +} diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts new file mode 100644 index 0000000..c1b1ba0 --- /dev/null +++ b/src/i18n/locales/zh-TW.ts @@ -0,0 +1,170 @@ +export default { + common: { + appName: '投資計劃排期', + language: '語言', + theme: '主題', + themeLight: '淺色', + themeDark: '深色', + }, + recurring: { + title: '定期投資', + lead: '設定定期定額投資計劃,系統將按設定間隔自動執行買入指示。', + myPlans: '我的定期投資計劃', + runningCount: '{n} 項進行中', + planEmpty: '暫無定期投資計劃', + colAsset: '資產', + colAmount: '金額/數量', + colInterval: '間隔', + colStart: '開始日期', + colEnd: '結束日期', + colStatus: '狀態', + colAction: '操作', + cancel: '終止', + cancelConfirmTitle: '確定要終止此定期投資計劃?', + cancelConfirmOk: '確認終止', + cancelConfirmBack: '取消', + terminateDialogTitle: '終止定期投資計劃', + terminateDialogLead: '終止後將不再執行新的買入指示,已執行之買入不受影響。', + terminateReasonLabel: '終止原因(選填)', + terminateReasonPlaceholder: '可填寫終止原因', + terminateReasonHint: '可不填,直接確認終止。', + planDetail: { + detail: '詳情', + title: '計劃詳情', + planNo: '計劃編號', + exchange: '交易所', + product: '產品', + investment: '每次投資', + orderPrice: '訂單價格', + frequency: '定投週期', + ctOffset: '交易時間偏移', + startDate: '開始日期', + endCondition: '結束條件', + nextExecution: '下次執行日', + progress: '執行進度', + progressValue: '已執行 {done} / 共 {total} 期', + progressOpen: '已執行 {done} 期', + status: '狀態', + createdAt: '建立時間', + channel: '渠道', + recentExecutions: '最近執行', + executionEmpty: '暫無執行記錄', + colScheduledDate: '計劃執行日', + colActualDate: '實際交易日', + colExecutionStatus: '執行狀態', + colOrderStatus: '委託狀態', + colAmount: '金額/數量', + missingPlanNo: '缺少計劃編號,無法查詢詳情', + }, + createTitle: '創建定期投資', + createHint: '支援股票及 ETF', + exchange: '交易所', + asset: '資產', + assetPlaceholder: '輸入代號搜尋', + assetSupport: '支持的: 股票、ETF', + assetEmpty: '沒有匹配的資產', + assetLoading: '載入中…', + removeAsset: '移除 {code}', + startDate: '開始日期', + amount: '金額', + amountHint: '每次執行買入之金額(HKD)', + quantity: '數量', + amountPlaceholder: '例如 10,000', + quantityPlaceholder: '例如 100', + quantityHint: '每次執行買入之股數', + investmentTypeLabel: '投資類型', + investmentType: { + AMOUNT: '按金額', + QUANTITY: '按數量', + }, + orderPriceTypeLabel: '訂單價格類型', + orderPriceType: { + MARKET: '市價', + LIMIT: '限價', + }, + limitPrice: '限價', + limitPricePlaceholder: '例如 350.5', + limitPriceHint: '限價單須填寫大於 0 的價格', + frequencyLabel: '定投週期', + frequency: { + DAILY: '每日', + WEEKLY: '每周', + MONTHLY: '每月', + }, + frequencyDayLabel: '執行日', + frequencyDayWeeklyHint: '每周固定星期執行', + frequencyDayMonthlyHint: '每月固定日期執行(1–31)', + monthDayOption: '每月 {day} 日', + weekday: { + MON: '星期一', + TUE: '星期二', + WED: '星期三', + THU: '星期四', + FRI: '星期五', + SAT: '星期六', + SUN: '星期日', + }, + ctOffsetLabel: '交易時間偏移(分鐘)', + ctOffsetHint: '範圍 0–240,相對開市時間的偏移', + ctOffsetPreview: '{n} 分鐘', + previewInvestmentLabel: '每次投資', + previewQuantity: '{n} 股', + intervalLabel: '間隔', + pleaseSelect: '請選擇', + endModeLabel: '結束類型', + endByDate: '特定日期', + endByCount: '期數', + endDate: '結束日期', + noEndDate: '無結束日期', + endCount: '期數', + endCountPlaceholder: '例如 12', + endCountHint: '計劃執行滿指定期數後自動終止', + endCountValue: '期數 {n} 期', + notesTitle: '注意事項', + notes: { + n1: '定期投資指示於執行日開市時以市價單執行,成交價以實際成交為準。', + n2: '本功能不支援不足一股的股份。定期投資的買入股數將向下取整至整股,因此可能產生碎股(零股)。', + n3: '如執行日為非交易日,將順延至下一個交易日執行。', + n4: '帳戶可用資金不足時,該期指示將不獲執行,計劃仍會繼續。', + n5: '計劃可隨時終止,已執行之買入不可撤回。', + }, + reset: '重設', + previewNextExecution: '預覽下次執行', + previewNextExecutionTitle: '下次執行預覽', + previewNextExecutionFill: '請先選擇交易所、定投週期與時間偏移', + previewScheduledDate: '週期日期', + previewExecutionDate: '執行日期', + previewExecutionDateTime: '執行時間', + previewDeferred: '非交易日順延', + previewDeferredYes: '是', + previewDeferredNo: '否', + continue: '繼續', + confirmTitle: '確認定期投資計劃', + eachAmount: '每次金額', + endPreview: '結束', + confirmNote: '計劃建立後將於每個執行日自動扣除資金並買入,請確保帳戶有足夠可用結餘。', + back: '返回', + confirmCreate: '確認建立', + fillRequired: '請填妥所有必填欄位', + createdToast: '定期投資計劃已建立', + cancelledToast: '計劃已終止,已執行之買入不受影響', + interval: { + daily: '每日', + weekly: '每周', + biweekly: '每兩周', + monthly: '月度', + quarterly: '季度', + semiannual: '每半年', + annual: '每年', + }, + status: { + active: '進行中', + created: '已建立', + cancelled: '已終止', + }, + assetType: { + etf: 'ETF', + stock: '股票', + }, + }, +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..cc35c72 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,35 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { parsePreferencesFromSearch } from '@/config/urlPreferences' +import { useAuthStore } from '@/stores/auth' +import { useServiceI18nStore } from '@/stores/serviceI18n' +import { hasToken } from '@/utils/token' +import App from './App.vue' +import i18n from './i18n' +import router from './router' +import { applyTheme } from './theme/applyTheme' +import 'element-plus/theme-chalk/dark/css-vars.css' +import 'element-plus/es/components/message/style/css' +import './styles/main.scss' + +const bootstrapPreferences = parsePreferencesFromSearch(window.location.search) +applyTheme(bootstrapPreferences.theme) +document.documentElement.lang = bootstrapPreferences.locale + +async function bootstrap() { + const app = createApp(App) + const pinia = createPinia() + + app.use(pinia) + useAuthStore().initFromUrl() + + if (hasToken()) { + await useServiceI18nStore().reload().catch(() => undefined) + } + + app.use(router) + app.use(i18n) + app.mount('#app') +} + +bootstrap() diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..4b3d099 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,21 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAppStore } from '@/stores/app' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'recurring-investment', + component: () => import('@/views/RecurringInvestmentView.vue'), + meta: { titleKey: 'recurring.title' }, + }, + ], +}) + +router.beforeEach((to) => { + const appStore = useAppStore() + appStore.syncFromRouteQuery(to.query) +}) + +export default router diff --git a/src/stores/app.ts b/src/stores/app.ts new file mode 100644 index 0000000..155f2fa --- /dev/null +++ b/src/stores/app.ts @@ -0,0 +1,54 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { LocationQuery } from 'vue-router' +import { + parsePreferencesFromQuery, + preferencesToQuery, + type AppPreferences, +} from '@/config/urlPreferences' +import i18n from '@/i18n' +import { APP_LOCALES, type AppLocale } from '@/config/appLocale' +import { applyTheme } from '@/theme/applyTheme' +import { APP_THEMES, type AppTheme } from '@/theme/types' + +export const useAppStore = defineStore('app', () => { + const initial = parsePreferencesFromQuery({}) + const locale = ref(initial.locale) + const theme = ref(initial.theme) + + function applyPreferences(preferences: AppPreferences) { + locale.value = preferences.locale + i18n.global.locale.value = preferences.locale + document.documentElement.lang = preferences.locale + + theme.value = preferences.theme + applyTheme(preferences.theme) + } + + function syncFromRouteQuery(query: LocationQuery) { + applyPreferences(parsePreferencesFromQuery(query)) + } + + function buildQueryForPreferences( + partial: Partial, + existing: LocationQuery, + ): Record { + return preferencesToQuery( + { + locale: partial.locale ?? locale.value, + theme: partial.theme ?? theme.value, + }, + existing, + ) + } + + return { + locale, + theme, + localeOptions: APP_LOCALES, + themeOptions: APP_THEMES, + applyPreferences, + syncFromRouteQuery, + buildQueryForPreferences, + } +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..75df7b2 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { LocationQuery } from 'vue-router' +import { + parseAccountContextFromQuery, + parseAccountContextFromSearch, + type UrlAccountContext, +} from '@/config/urlContext' +import { getToken, getTokenFromUrl, removeToken, setToken } from '@/utils/token' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(getToken()) + const clientId = ref(null) + const accountId = ref(null) + + function applyAccountContext(context: UrlAccountContext) { + clientId.value = context.clientId + accountId.value = context.accountId + } + + /** URL 优先,其次沿用本地已存 token;账户上下文仅来自 URL */ + function initFromUrl() { + const urlToken = getTokenFromUrl() + if (urlToken) { + setToken(urlToken) + token.value = urlToken + } else if (token.value) { + setToken(token.value) + } + applyAccountContext(parseAccountContextFromSearch(window.location.search)) + } + + function syncContextFromQuery(query: LocationQuery) { + applyAccountContext(parseAccountContextFromQuery(query)) + } + + function setTokenManually(value: string) { + setToken(value) + token.value = value + } + + function clearToken() { + removeToken() + token.value = null + } + + return { + token, + clientId, + accountId, + initFromUrl, + syncContextFromQuery, + setTokenManually, + clearToken, + } +}) diff --git a/src/stores/recurringPlans.ts b/src/stores/recurringPlans.ts new file mode 100644 index 0000000..32fba34 --- /dev/null +++ b/src/stores/recurringPlans.ts @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import type { RecurringPlan } from '@/types/recurringInvestment' +import { isPlanRunningStatus } from '@/utils/scheduleInvestmentPlanMapper' + +export const useRecurringPlansStore = defineStore('recurringPlans', () => { + const plans = ref([]) + + const runningCount = computed( + () => + plans.value.filter((plan) => + plan.apiStatus ? isPlanRunningStatus(plan.apiStatus) : plan.status !== 'cancelled', + ).length, + ) + + function setPlans(items: RecurringPlan[]) { + plans.value = items + } + + function cancelPlan(id: string) { + const plan = plans.value.find((item) => item.id === id) + if (!plan || plan.status === 'cancelled') { + return false + } + plan.status = 'cancelled' + plan.apiStatus = 'TERMINATED' + return true + } + + return { + plans, + runningCount, + setPlans, + cancelPlan, + } +}) diff --git a/src/stores/serviceI18n.ts b/src/stores/serviceI18n.ts new file mode 100644 index 0000000..db214c3 --- /dev/null +++ b/src/stores/serviceI18n.ts @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getSystemI18nAllList, type SystemI18n } from '@/api/base' +import type { AppLocale } from '@/config/appLocale' + +type ServiceLangKey = 'sc' | 'tc' | 'en' + +const localeToMapKey: Record = { + 'zh-CN': 'sc', + 'zh-TW': 'tc', + 'en-US': 'en', +} + +let reloadingPromise: Promise | null = null +const loadingIds: string[] = [] + +export const useServiceI18nStore = defineStore('serviceI18n', () => { + const maps = ref>>({ + sc: new Map(), + tc: new Map(), + en: new Map(), + }) + + function initFromList(list: SystemI18n[]) { + list.forEach((item) => { + maps.value.sc.set(item.id, item.scDescription) + maps.value.tc.set(item.id, item.tcDescription) + maps.value.en.set(item.id, item.enDescription) + }) + } + + function getText(id: string | undefined, locale: AppLocale): string { + if (!id) { + return '' + } + const key = localeToMapKey[locale] ?? 'en' + const text = maps.value[key].get(id) + if (text) { + return text + } + if (import.meta.env.DEV) { + console.warn('No service i18n:', id) + } + if (!loadingIds.includes(id)) { + loadingIds.push(id) + reload().finally(() => { + const index = loadingIds.indexOf(id) + if (index > -1) { + loadingIds.splice(index, 1) + } + }) + } + return id + } + + async function reload() { + if (reloadingPromise) { + return reloadingPromise + } + reloadingPromise = (async () => { + try { + const result = await getSystemI18nAllList() + if (result?.data) { + initFromList(result.data) + } + } catch (e) { + console.error('Failed to load service i18n:', e) + } finally { + reloadingPromise = null + } + })() + return reloadingPromise + } + + return { + maps, + getText, + reload, + initFromList, + } +}) diff --git a/src/styles/breakpoints.scss b/src/styles/breakpoints.scss new file mode 100644 index 0000000..6815915 --- /dev/null +++ b/src/styles/breakpoints.scss @@ -0,0 +1,19 @@ +@use 'sass:map'; + +// 数值须与 src/constants/breakpoints.ts 一致 +$breakpoints: ( + sm: 576px, + md: 768px, + lg: 1024px, + xl: 1280px, +); + +$layout-max-width: 1280px; + +@function breakpoint($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error "Unknown breakpoint `#{$name}`."; + } + @return $value; +} diff --git a/src/styles/element-plus-theme.scss b/src/styles/element-plus-theme.scss new file mode 100644 index 0000000..5c13133 --- /dev/null +++ b/src/styles/element-plus-theme.scss @@ -0,0 +1,26 @@ +// Align Element Plus form controls with app semantic tokens (incl. hover/focus borders). + +@mixin ep-form-control-vars { + --el-color-primary: var(--color-primary); + --el-fill-color-blank: var(--color-control-bg); + --el-border-color: var(--color-control-border); + --el-border-color-hover: var(--color-primary); + --el-text-color-regular: var(--color-text); + --el-text-color-placeholder: var(--color-muted); + --el-input-bg-color: var(--color-control-bg); + --el-input-border-color: var(--color-control-border); + --el-input-hover-border-color: var(--color-primary); + --el-input-focus-border-color: var(--color-primary); +} + +:root, +[data-theme='light'] { + @include ep-form-control-vars; + --el-color-primary-rgb: 14, 124, 123; +} + +html.dark, +[data-theme='dark'] { + @include ep-form-control-vars; + --el-color-primary-rgb: 47, 191, 174; +} diff --git a/src/styles/layout.scss b/src/styles/layout.scss new file mode 100644 index 0000000..8e5e18c --- /dev/null +++ b/src/styles/layout.scss @@ -0,0 +1,38 @@ +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-container { + width: 100%; + max-width: var(--layout-max-width); + margin-inline: auto; + padding-inline: var(--page-padding-x); + padding-block: var(--page-padding-y); + flex: 1; +} + +.app-page { + width: 100%; +} + +.app-page__title { + margin: 0 0 1rem; + font-size: var(--font-size-title); + font-weight: 600; + line-height: 1.3; +} + +.app-page__lead { + margin: 0 0 1.5rem; + font-size: var(--font-size-lead); + line-height: 1.6; + color: var(--color-muted); +} + +.app-table-scroll { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000..f984215 --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,124 @@ +@use './variables' as *; +@use './layout'; +@use './element-plus-theme'; + +:root, +[data-theme='light'] { + color-scheme: light; + --color-bg: #f2f2f2; + --color-text: #082d4f; + --color-text-2: #3d546e; + --color-border: #e2e7ec; + --color-border-strong: #cfd8e0; + --color-surface: #ffffff; + --color-surface-2: #f7f9fa; + --color-muted: #7186a0; + --color-primary: #{$color-primary}; + --color-primary-soft: rgb(14 124 123 / 9%); + --color-primary-ink: #0a5c5b; + --color-up: #0f9d6e; + --color-up-soft: rgb(15 157 110 / 10%); + --color-down: #e03e46; + --color-down-soft: rgb(224 62 70 / 9%); + --color-warn: #c77414; + --color-warn-soft: rgb(199 116 20 / 10%); + --color-control-bg: #ffffff; + --color-control-border: #cfd8e0; + --radius: 4px; + --font-mono: ui-monospace, 'Cascadia Code', 'SFMono-Regular', Menlo, Consolas, monospace; +} + +[data-theme='dark'] { + color-scheme: dark; + --color-bg: #0a0f16; + --color-text: #e9eef4; + --color-text-2: #b8c5d4; + --color-border: #1d2a3a; + --color-border-strong: #28394d; + --color-surface: #101824; + --color-surface-2: #16202e; + --color-muted: #7d8ea0; + --color-primary: #2fbfae; + --color-primary-soft: rgb(47 191 174 / 12%); + --color-primary-ink: #2fbfae; + --color-up: #34d399; + --color-up-soft: rgb(52 211 153 / 12%); + --color-down: #f87171; + --color-down-soft: rgb(248 113 113 / 12%); + --color-warn: #fbbf24; + --color-warn-soft: rgb(251 191 36 / 12%); + --color-control-bg: #101824; + --color-control-border: #28394d; + --radius: 4px; + --font-mono: ui-monospace, 'Cascadia Code', 'SFMono-Regular', Menlo, Consolas, monospace; +} + +:root { + --layout-max-width: #{$layout-max-width}; + --page-padding-x: 16px; + --page-padding-y: 16px; + --font-size-base: 14px; + --font-size-lead: 16px; + --font-size-title: 20px; +} + +@media (min-width: 768px) { + :root { + --page-padding-x: 24px; + --page-padding-y: 24px; + --font-size-lead: 17px; + --font-size-title: 22px; + } +} + +@media (min-width: 1024px) { + :root { + --page-padding-x: 32px; + --page-padding-y: 32px; + --font-size-base: 15px; + --font-size-lead: 18px; + --font-size-title: 24px; + } +} + +html { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: var(--font-size-base); + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + color: var(--color-text); + background-color: var(--color-bg); + transition: + color 0.2s ease, + background-color 0.2s ease; +} + +a { + color: var(--color-muted); + text-decoration: none; + + &:hover { + color: var(--color-primary); + } +} + +#app { + width: 100%; + min-height: 100vh; +} diff --git a/src/styles/mixins/_respond.scss b/src/styles/mixins/_respond.scss new file mode 100644 index 0000000..f074eab --- /dev/null +++ b/src/styles/mixins/_respond.scss @@ -0,0 +1,22 @@ +@use '../breakpoints' as bp; + +@mixin respond-up($name) { + @media (min-width: bp.breakpoint($name)) { + @content; + } +} + +@mixin respond-down($name) { + $width: bp.breakpoint($name); + @media (max-width: ($width - 0.02px)) { + @content; + } +} + +@mixin respond-between($from, $to) { + $min: bp.breakpoint($from); + $max: bp.breakpoint($to); + @media (min-width: $min) and (max-width: ($max - 0.02px)) { + @content; + } +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000..e382654 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,2 @@ +$color-primary: #0e7c7b; +$layout-max-width: 1280px; diff --git a/src/theme/applyTheme.ts b/src/theme/applyTheme.ts new file mode 100644 index 0000000..68b6966 --- /dev/null +++ b/src/theme/applyTheme.ts @@ -0,0 +1,7 @@ +import type { AppTheme } from './types' + +export function applyTheme(theme: AppTheme) { + document.documentElement.setAttribute('data-theme', theme) + document.documentElement.style.colorScheme = theme + document.documentElement.classList.toggle('dark', theme === 'dark') +} diff --git a/src/theme/types.ts b/src/theme/types.ts new file mode 100644 index 0000000..af671f7 --- /dev/null +++ b/src/theme/types.ts @@ -0,0 +1,5 @@ +export const APP_THEMES = ['light', 'dark'] as const + +export type AppTheme = (typeof APP_THEMES)[number] + +export const DEFAULT_THEME: AppTheme = 'dark' diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..c2d57a1 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,5 @@ +export interface ApiResponse { + code: string | number + message: string + data: T +} diff --git a/src/types/auto-imports.d.ts b/src/types/auto-imports.d.ts new file mode 100644 index 0000000..193bc01 --- /dev/null +++ b/src/types/auto-imports.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +/* prettier-ignore */ +/* oxlint-disable */ +/* oxfmt-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/src/types/components.d.ts b/src/types/components.d.ts new file mode 100644 index 0000000..511c031 --- /dev/null +++ b/src/types/components.d.ts @@ -0,0 +1,34 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AppPage: typeof import('./../components/layout/AppPage.vue')['default'] + AssetChipPicker: typeof import('./../components/recurring/AssetChipPicker.vue')['default'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/src/types/recurringInvestment.ts b/src/types/recurringInvestment.ts new file mode 100644 index 0000000..efef19e --- /dev/null +++ b/src/types/recurringInvestment.ts @@ -0,0 +1,79 @@ +export type AssetType = 'etf' | 'stock' + +export type IntervalKey = + | 'daily' + | 'weekly' + | 'biweekly' + | 'monthly' + | 'quarterly' + | 'semiannual' + | 'annual' + +export type EndMode = 'date' | 'count' + +export type PlanStatus = 'active' | 'created' | 'cancelled' + +export interface InvestAsset { + code: string + name: string + type: AssetType + /** 产品英文名称(来自 getSimpleStockPage) */ + engName?: string + /** 产品本地名称(来自 getSimpleStockPage) */ + localName?: string + /** 对接产品接口时的主键,如 SZMK-2728 */ + productKey?: string + exchangeCode?: string + productCodeUnique?: string +} + +export interface RecurringPlan { + id: string + /** 接口计划编号,如 SI-2026-10-08-… */ + planNo?: string + assets: InvestAsset[] + amount: number + /** 按数量定投时的每次股数 */ + quantity?: number | null + investmentType?: 'AMOUNT' | 'QUANTITY' + frequencyType?: 'DAILY' | 'WEEKLY' | 'MONTHLY' + frequencyDay?: string + interval: IntervalKey + startDate: string + endMode: EndMode + endDate: string + endCount: number | null + noEnd: boolean + endDisplay: string + status: PlanStatus + /** 接口原始状态,如 ACTIVE */ + apiStatus?: string +} + +export interface RecurringPlanDraft { + assets: InvestAsset[] + amount: number + interval: IntervalKey + startDate: string + endDisplay: string + endMode: EndMode + endDate: string + endCount: number | null + noEnd: boolean + investmentType?: 'AMOUNT' | 'QUANTITY' + quantity?: number + orderPriceType?: 'MARKET' | 'LIMIT' + limitPrice?: number + frequencyDay?: string + ctOffsetMinutes?: number +} + +export const INTERVAL_KEYS: IntervalKey[] = [ + 'daily', + 'weekly', + 'biweekly', + 'monthly', + 'quarterly', + 'semiannual', + 'annual', +] diff --git a/src/types/scheduleInvestment.ts b/src/types/scheduleInvestment.ts new file mode 100644 index 0000000..8e5831d --- /dev/null +++ b/src/types/scheduleInvestment.ts @@ -0,0 +1,205 @@ +/** 创建定期投资计划 — 与交易接口字段对齐 */ + +export type InvestmentType = 'AMOUNT' | 'QUANTITY' + +export type OrderPriceType = 'MARKET' | 'LIMIT' + +export type FrequencyType = 'DAILY' | 'WEEKLY' | 'MONTHLY' + +export type ScheduleEndType = 'DATE' | 'COUNT' | 'NONE' + +export type WeekdayCode = 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | 'SUN' + +export const FREQUENCY_TYPE_KEYS: FrequencyType[] = ['DAILY', 'WEEKLY', 'MONTHLY'] + +export const WEEKDAY_CODES: WeekdayCode[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] + +export const INVESTMENT_TYPE_KEYS: InvestmentType[] = ['AMOUNT', 'QUANTITY'] + +export const ORDER_PRICE_TYPE_KEYS: OrderPriceType[] = ['MARKET', 'LIMIT'] + +/** `POST /trade/schedule-investment/exchanges` 单条 */ +export interface ScheduleInvestmentExchangeItem { + exchangeCode: string + nameI18nId: string + tradeDate: string +} + +/** `POST /trade/schedule-investment/plan/page` 查询条件(筛选项可传空,仅用分页) */ +export interface ScheduleInvestmentPlanPageRequest { + current: number + size: number + accountCode?: string + exchangeCode?: string + productCode?: string + status?: string + investmentType?: string + orderPriceType?: string + startDate?: string + endDate?: string +} + +export interface ScheduleInvestmentPlanPageData { + current: number + maxId: string | null + records: ScheduleInvestmentPlanRecord[] + size: number + total: number | string + totalPages: number +} + +/** 计划列表单条(字段以接口为准) */ +export interface ScheduleInvestmentPlanRecord { + id: string + planNo: string + accountId: string + accountCode?: string + clientId: string + clientCode?: string + exchangeCode: string + productCode: string + productEngName?: string + productLocalName?: string + investmentType: InvestmentType + amountPerExecution: string | null + qtyPerExecution: string | null + orderPriceType: OrderPriceType + frequencyType: FrequencyType + frequencyDay?: string | null + startDate: string + endDate?: string | null + endType: ScheduleEndType + totalExecutionCount?: number | null + executedCount?: number + ctOffsetMinutes: number + status: string + nextExecutionDate?: string | null + currencyCode?: string + channelCode?: string + createOn?: string + updateOn?: string +} + +/** `POST /trade/schedule-investment/plan/detail` 请求体 */ +export interface ScheduleInvestmentPlanDetailRequest { + planNo: string +} + +/** `POST /trade/schedule-investment/plan/terminate` 请求体 */ +export interface ScheduleInvestmentPlanTerminateRequest { + planNo: string + /** 选填 */ + terminateReason?: string +} + +/** `POST /trade/schedule-investment/plan/next-execution` 请求体 */ +export interface ScheduleInvestmentNextExecutionRequest { + exchangeCode: string + frequencyType: FrequencyType + /** WEEKLY / MONTHLY 时必填 */ + frequencyDay?: string + /** 空或 0 表示 CT 开市时刻;最大 240 */ + ctOffsetMinutes?: number + /** 空表示交易所交易日 */ + startDate?: string +} + +/** `POST /trade/schedule-investment/plan/next-execution` 响应 data */ +export interface ScheduleInvestmentNextExecutionData { + scheduledDate: string + executionDate: string + executionDateTime: string + deferred: boolean + ctOffsetMinutes: number +} + +/** 计划详情 — plan 字段(比列表项更全) */ +export interface ScheduleInvestmentPlanDetailPlan { + id: string | number + planNo: string + accountId?: string | number + accountCode?: string + clientId?: string | number + clientCode?: string + exchangeCode: string + marketId?: string | number | null + productCode: string + productEngName?: string + productLocalName?: string + currencyCode?: string + investmentType: InvestmentType + amountPerExecution?: string | number | null + qtyPerExecution?: string | number | null + orderPriceType: OrderPriceType + limitPrice?: string | number | null + frequencyType: FrequencyType + frequencyDay?: string | null + startDate: string + endType: ScheduleEndType + endDate?: string | null + totalExecutionCount?: number | string | null + executedCount?: number | string + ctOffsetMinutes: number + nextExecutionDate?: string | null + status: string + lastExecutionStatus?: string + lastExecutionTime?: string | null + terminateTime?: string | null + terminateBy?: string + terminateReason?: string + channelCode?: string + createBy?: string + createOn?: string + updateBy?: string + updateOn?: string + userId?: string | number + userCode?: string +} + +export interface ScheduleInvestmentPlanExecutionRecord { + id?: string | number + executionNo?: string + planNo?: string + scheduledExecutionDate?: string + actualTradeDate?: string + scheduledSubmitTime?: string + actualSubmitTime?: string + investmentType?: InvestmentType + amount?: string | number | null + inputQty?: string | number | null + calculatedQty?: string | number | null + referencePrice?: string | number | null + orderPriceType?: OrderPriceType + limitPrice?: string | number | null + orderStatus?: string + executionStatus?: string + failureCode?: string + failureMessage?: string +} + +export interface ScheduleInvestmentPlanDetailData { + plan: ScheduleInvestmentPlanDetailPlan + recentExecutions: ScheduleInvestmentPlanExecutionRecord[] +} + +export interface ScheduleInvestmentCreateBody { + accountId: number | string + exchangeCode: string + productCode: string + productEngName?: string + productLocalName?: string + clientId: number | string + investmentType: InvestmentType + amountPerExecution?: number + qtyPerExecution?: number + orderPriceType: OrderPriceType + /** 限价单必填,须大于 0 */ + limitPrice?: number + frequencyType: FrequencyType + frequencyDay?: string + startDate: string + endType: ScheduleEndType + endDate?: string + totalExecutionCount?: number + ctOffsetMinutes: number +} diff --git a/src/utils/formatMoney.ts b/src/utils/formatMoney.ts new file mode 100644 index 0000000..c9697f3 --- /dev/null +++ b/src/utils/formatMoney.ts @@ -0,0 +1,11 @@ +export function parseAmountInput(raw: string): number { + const value = Number.parseFloat(raw.replace(/,/g, '').trim()) + return Number.isFinite(value) ? value : Number.NaN +} + +export function formatHkd(amount: number): string { + return `HKD ${amount.toLocaleString('en-HK', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` +} diff --git a/src/utils/investAssetDisplay.ts b/src/utils/investAssetDisplay.ts new file mode 100644 index 0000000..9dc4bc6 --- /dev/null +++ b/src/utils/investAssetDisplay.ts @@ -0,0 +1,65 @@ +import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale' +import type { InvestAsset } from '@/types/recurringInvestment' + +export function resolveAppLocale(locale: string | undefined): AppLocale { + if (locale && isAppLocale(locale)) { + return locale + } + return DEFAULT_LOCALE +} + +/** 按界面语言在本地名与英文名之间选取 */ +export function pickProductBilingualName( + locale: AppLocale, + productLocalName?: string, + productEngName?: string, +): string { + const local = productLocalName?.trim() ?? '' + const eng = productEngName?.trim() ?? '' + if (locale === 'en-US') { + return eng || local + } + return local || eng +} + +/** 界面展示用产品代号(优先交易所唯一码,如 1.HK) */ +export function investAssetDisplayCode(asset: Pick): string { + const unique = asset.productCodeUnique?.trim() + if (unique) { + return unique + } + return asset.code.trim() +} + +export function investAssetDisplayName( + asset: Pick, + locale?: AppLocale, +): string { + const picked = locale + ? pickProductBilingualName(locale, asset.localName, asset.engName) + : asset.localName?.trim() || asset.engName?.trim() || '' + if (picked) { + return picked + } + const name = asset.name?.trim() + return name || asset.code +} + +/** 计划详情等:`代号 · 名称` */ +export function scheduleProductLineDisplay( + locale: AppLocale, + productCode: string, + productLocalName?: string, + productEngName?: string, +): string { + const code = productCode.trim() + const name = pickProductBilingualName(locale, productLocalName, productEngName) + if (name && name !== code) { + return `${code} · ${name}` + } + return code +} + +export function investAssetOptionKey(asset: InvestAsset): string { + return asset.productKey || investAssetDisplayCode(asset) +} diff --git a/src/utils/scheduleInvestmentPayload.ts b/src/utils/scheduleInvestmentPayload.ts new file mode 100644 index 0000000..16e1dff --- /dev/null +++ b/src/utils/scheduleInvestmentPayload.ts @@ -0,0 +1,97 @@ +import type { InvestAsset } from '@/types/recurringInvestment' +import type { + FrequencyType, + InvestmentType, + OrderPriceType, + ScheduleEndType, + ScheduleInvestmentCreateBody, +} from '@/types/scheduleInvestment' +import type { IntervalKey } from '@/types/recurringInvestment' + +export interface ScheduleFormPayloadInput { + exchangeCode: string + asset: InvestAsset + investmentType: InvestmentType + amountPerExecution: number | null + qtyPerExecution: number | null + orderPriceType: OrderPriceType + limitPrice: number | null + frequencyType: FrequencyType + frequencyDay: string + startDate: string + endMode: 'date' | 'count' + endDate: string + noEnd: boolean + endCount: number | null + ctOffsetMinutes: number + clientId: string | null + accountId: string | null +} + +export function resolveEndType(input: Pick): ScheduleEndType { + if (input.endMode === 'count') { + return 'COUNT' + } + if (input.noEnd || !input.endDate.trim()) { + return 'NONE' + } + return 'DATE' +} + +export function buildScheduleInvestmentCreateBody( + input: ScheduleFormPayloadInput, +): ScheduleInvestmentCreateBody { + const endType = resolveEndType(input) + const productCode = input.asset.productCodeUnique || input.asset.code + + const body: ScheduleInvestmentCreateBody = { + accountId: input.accountId ?? '', + exchangeCode: input.exchangeCode, + productCode, + productEngName: input.asset.engName?.trim() ?? '', + productLocalName: input.asset.localName?.trim() ?? '', + clientId: input.clientId ?? '', + investmentType: input.investmentType, + orderPriceType: input.orderPriceType, + frequencyType: input.frequencyType, + startDate: input.startDate, + endType, + ctOffsetMinutes: input.ctOffsetMinutes, + } + + if (input.investmentType === 'AMOUNT' && input.amountPerExecution != null) { + body.amountPerExecution = input.amountPerExecution + } + if (input.investmentType === 'QUANTITY' && input.qtyPerExecution != null) { + body.qtyPerExecution = input.qtyPerExecution + } + + if (input.orderPriceType === 'LIMIT' && input.limitPrice != null && input.limitPrice > 0) { + body.limitPrice = input.limitPrice + } + + if (input.frequencyType === 'WEEKLY' || input.frequencyType === 'MONTHLY') { + body.frequencyDay = input.frequencyDay + } + + if (endType === 'DATE') { + body.endDate = input.endDate + } else { + body.endDate = '' + } + + if (endType === 'COUNT' && input.endCount != null) { + body.totalExecutionCount = input.endCount + } + + return body +} + +export function frequencyTypeToInterval(frequencyType: FrequencyType): IntervalKey { + const map: Record = { + DAILY: 'daily', + WEEKLY: 'weekly', + MONTHLY: 'monthly', + } + return map[frequencyType] +} diff --git a/src/utils/scheduleInvestmentPlanMapper.ts b/src/utils/scheduleInvestmentPlanMapper.ts new file mode 100644 index 0000000..6b6db82 --- /dev/null +++ b/src/utils/scheduleInvestmentPlanMapper.ts @@ -0,0 +1,109 @@ +import type { InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment' +import type { + FrequencyType, + ScheduleEndType, + ScheduleInvestmentPlanRecord, +} from '@/types/scheduleInvestment' +import { frequencyTypeToInterval } from '@/utils/scheduleInvestmentPayload' + +function parseDecimal(value: string | null | undefined): number { + if (value == null || value === '') { + return 0 + } + const n = Number.parseFloat(value) + return Number.isNaN(n) ? 0 : n +} + +export function mapApiPlanStatus(status: string): PlanStatus { + const normalized = status.trim().toUpperCase() + if ( + normalized === 'TERMINATED' || + normalized === 'CANCELLED' || + normalized === 'CANCELED' || + normalized === 'STOPPED' + ) { + return 'cancelled' + } + if (normalized === 'CREATED' || normalized === 'PENDING') { + return 'created' + } + return 'active' +} + +export function isPlanRunningStatus(status: string): boolean { + return mapApiPlanStatus(status) !== 'cancelled' +} + +function resolveEndFields(record: ScheduleInvestmentPlanRecord): Pick< + RecurringPlan, + 'endMode' | 'endDate' | 'endCount' | 'noEnd' | 'endDisplay' +> { + const endType = record.endType as ScheduleEndType + if (endType === 'COUNT' && record.totalExecutionCount != null) { + return { + endMode: 'count', + endDate: '', + endCount: record.totalExecutionCount, + noEnd: false, + endDisplay: '', + } + } + if (endType === 'NONE' || !record.endDate) { + return { + endMode: 'date', + endDate: '', + endCount: null, + noEnd: true, + endDisplay: '', + } + } + return { + endMode: 'date', + endDate: record.endDate ?? '', + endCount: null, + noEnd: false, + endDisplay: '', + } +} + +export function mapSchedulePlanRecordToRecurringPlan( + record: ScheduleInvestmentPlanRecord, +): RecurringPlan { + const code = record.productCode + const resolvedName = + record.productLocalName?.trim() || record.productEngName?.trim() || '' + const name = resolvedName && resolvedName !== code ? resolvedName : '' + const asset: InvestAsset = { + code, + name, + type: 'stock', + engName: record.productEngName?.trim() ?? '', + localName: record.productLocalName?.trim() ?? '', + exchangeCode: record.exchangeCode, + productCodeUnique: record.productCode, + } + const amount = + record.investmentType === 'AMOUNT' + ? parseDecimal(record.amountPerExecution) + : parseDecimal(record.qtyPerExecution) + const quantity = + record.investmentType === 'QUANTITY' ? parseDecimal(record.qtyPerExecution) : null + const endFields = resolveEndFields(record) + const frequencyType = record.frequencyType as FrequencyType + + return { + id: record.id, + planNo: record.planNo, + assets: [asset], + amount, + quantity, + investmentType: record.investmentType, + frequencyType, + frequencyDay: record.frequencyDay ?? undefined, + interval: frequencyTypeToInterval(frequencyType), + startDate: record.startDate, + ...endFields, + status: mapApiPlanStatus(record.status), + apiStatus: record.status, + } +} diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000..60451c5 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,23 @@ +const TOKEN_KEY = 'auth_token' + +/** 从 URL 查询参数读取 token(例如 ?token=xxx) */ +export function getTokenFromUrl(): string | null { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get('token') +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function removeToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +export function hasToken(): boolean { + return !!getToken() +} diff --git a/src/views/RecurringInvestmentView.vue b/src/views/RecurringInvestmentView.vue new file mode 100644 index 0000000..2405b94 --- /dev/null +++ b/src/views/RecurringInvestmentView.vue @@ -0,0 +1,2111 @@ + + + + + diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..2d28a9a --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5298209 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,66 @@ +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { defineConfig, loadEnv } from 'vite' + +const DEFAULT_PROXY_TARGET = 'http://192.168.3.230:18000' + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const proxyTarget = (env.VITE_API_PROXY_TARGET || DEFAULT_PROXY_TARGET).trim().replace(/\/+$/, '') + + return { + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + dts: 'src/types/auto-imports.d.ts', + }), + Components({ + resolvers: [ElementPlusResolver({ importStyle: 'sass' })], + dts: 'src/types/components.d.ts', + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 5173, + open: true, + proxy: { + '/base': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, + '/trade': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, + '/product': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, + '/api': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, + }, + }, + css: { + preprocessorOptions: { + scss: { + additionalData: `@use "@/styles/mixins/respond" as *;\n`, + }, + }, + }, + } +})