实现 CT 时间偏移选择并优化 URL 语言处理:新增获取 CT 时间偏移选项接口,表单改用下拉选择;URL 语言参数统一为 zh/tw/en 并兼容旧版;Vite 支持局域网访问,更新开发文档。
This commit is contained in:
@@ -11,7 +11,7 @@ alwaysApply: true
|
|||||||
## 语言与主题(仅 URL)
|
## 语言与主题(仅 URL)
|
||||||
|
|
||||||
- 查询参数:`lang`(或 `locale`)、`theme`;**禁止**用 `localStorage` 持久化语言/主题。
|
- 查询参数:`lang`(或 `locale`)、`theme`;**禁止**用 `localStorage` 持久化语言/主题。
|
||||||
- 缺省:`lang=zh-TW`、`theme=dark`;`theme` 支持别名 `light`/`day`、`dark`/`night`。
|
- URL `lang` 取值:`zh` / `tw` / `en`(缺省 `tw`);兼容读取 `zh-CN` / `zh-TW` / `en-US`;写入地址栏统一短码。`theme` 缺省 `dark`,支持别名 `light`/`day`、`dark`/`night`。
|
||||||
- 解析与写入:`src/config/urlPreferences.ts`;切换语言/主题用 `useUrlPreferences()`(更新 `router.replace` 的 query)。
|
- 解析与写入:`src/config/urlPreferences.ts`;切换语言/主题用 `useUrlPreferences()`(更新 `router.replace` 的 query)。
|
||||||
- 路由跳转保留 query:`useRouteWithPreferences().linkTo(path)` 或等价 `{ path, query: route.query }`。
|
- 路由跳转保留 query:`useRouteWithPreferences().linkTo(path)` 或等价 `{ path, query: route.query }`。
|
||||||
- i18n:`src/i18n/`(简/繁/英);Element Plus 语言:`getElementPlusLocale` + 根节点 `el-config-provider`。
|
- i18n:`src/i18n/`(简/繁/英);Element Plus 语言:`getElementPlusLocale` + 根节点 `el-config-provider`。
|
||||||
|
|||||||
@@ -480,6 +480,40 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单 CT 偏移选项接口(2026-10-10)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:「交易時間偏移」由 `POST /trade/schedule-investment/options/ct-time`(`exchangeCode`、`productCode`)拉取选项,下拉展示 `displayName`,取值 `offsetMinutes`。
|
||||||
|
- **涉及模块**:`src/api/scheduleInvestment.ts`、`src/types/scheduleInvestment.ts`、`RecurringInvestmentView.vue`、i18n(zh-TW / zh-CN / en-US)。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `getScheduleInvestmentCtTimeOptions` | 新增 ct-time 接口封装。 |
|
||||||
|
| `ScheduleInvestmentCtTimeOptionsRequest` / `Data` / `Option` | 描述请求与 `options` 列表结构。 |
|
||||||
|
| `loadCtOffsetOptions` / `watch` | 交易所与标的变更时请求;当前偏移不在列表时回退首项。 |
|
||||||
|
| 创建表单 CT 字段 | `el-input-number` 改为可筛选 `el-select`;未选标的时禁用。 |
|
||||||
|
| `isCtOffsetValid` | 有选项时校验是否在列表内;无选项时保留 0–240 兜底。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅创建计划表单;创建/预览请求体仍传 `ctOffsetMinutes` 数值。
|
||||||
|
- **破坏性变更**:否(交互由手输改为下拉,选项由后端决定)。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低(依赖交易所+产品;失败时选项为空、字段禁用,提交校验不通过)。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`;选择交易所与资产后确认下拉加载与提交偏移值正确。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 补充:计划列表金额/数量展示(2026-10-09)
|
## 补充:计划列表金额/数量展示(2026-10-09)
|
||||||
|
|
||||||
### 1. 改动概览
|
### 1. 改动概览
|
||||||
@@ -606,3 +640,69 @@
|
|||||||
|
|
||||||
- `npm run build`。
|
- `npm run build`。
|
||||||
- 冒烟:定期投资列表/创建/详情/终止、语言切换。
|
- 冒烟:定期投资列表/创建/详情/终止、语言切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:URL `lang` 短码与 README(2026-10-10)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:文档与地址栏统一使用 `zh` / `tw` / `en` 作为 `lang` 查询参数;完善 README。
|
||||||
|
- **涉及模块**:`src/config/appLocale.ts`、`src/config/urlPreferences.ts`、`README.md`、`.cursor/rules/project-core.mdc`。
|
||||||
|
- **改动类型**:配置/文档 + 行为小幅调整。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `appLocale.ts` | 新增 `URL_LANG_CODES`、`resolveLocaleFromLangParam`、`localeToUrlLang`;短码映射到内部 `zh-CN` / `zh-TW` / `en-US`。 |
|
||||||
|
| `urlPreferences.ts` | 读入经 `resolveLocaleFromLangParam`;写出 `preferencesToQuery` 使用短码。 |
|
||||||
|
| `README.md` | 补充环境、目录、URL 参数表与示例(`lang=zh|tw|en`)。 |
|
||||||
|
|
||||||
|
仍接受旧 URL `lang=zh-TW` 等;切换或写入 query 时地址栏为 `tw` 等短码。
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- **调用方**:`parsePreferencesFromSearch` / `parsePreferencesFromQuery` / `preferencesToQuery`(`main.ts`、`app` store、`useUrlPreferences`)。
|
||||||
|
- 内部 `AppLocale` 与 vue-i18n 键名不变;仅 URL 展示与解析规则扩展。
|
||||||
|
- **破坏性变更**:否(旧长格式仍可读;书签若依赖写出为长格式,首次 replace 后会变为短码)。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是(还原 `appLocale` / `urlPreferences` 与文档)。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`:通过。
|
||||||
|
- 建议手工:`?lang=zh`、`?lang=tw`、`?lang=en` 及 `?lang=zh-TW` 兼容读取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:Vite 开发服局域网 / 隧道访问(2026-10-10)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:`npm run dev` / `preview` 可通过本机 IP 或穿透域名访问,不限于 localhost。
|
||||||
|
- **涉及模块**:`vite.config.ts`、`README.md`。
|
||||||
|
- **改动类型**:配置变更。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `vite.config.ts` | `server.host: true`、`server.allowedHosts: true`;`preview` 同步 `host` / `allowedHosts`。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅本地/内网开发体验;生产构建产物不变。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低(开发机需自行注意防火墙与勿暴露敏感环境)。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run dev`:终端出现 Network URL;同网段可打开页面。
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Schedule Invest Plan
|
# Schedule Invest Plan
|
||||||
|
|
||||||
Vue 3 + TypeScript 前端项目,技术栈:Vite、Vue Router、Pinia、Axios、vue-i18n、Sass、Element Plus。
|
Vue 3 + TypeScript 前端:定期投资计划页面。技术栈包括 Vite、Vue Router、Pinia、Axios、vue-i18n、Sass、ECharts、Element Plus(按需自动导入)。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 18+(建议与团队 CI 一致)
|
||||||
|
- npm
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -9,54 +14,99 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
默认开发地址:`http://localhost:5173/`。`vite.config.ts` 已设置 `server.host: true`,启动后终端会打印 **Network** 地址,同网段设备可用本机 IP 访问(如 `http://192.168.x.x:5173/`);经内网穿透或自定义域名访问时依赖 `allowedHosts: true`。
|
||||||
|
|
||||||
|
未带查询参数时,语言为繁体(`lang=tw`)、主题为深色(`theme=dark`)。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build # vue-tsc + vite build
|
||||||
npm run preview
|
npm run preview # 预览生产构建
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 文件 | 变量 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `.env.development` | `VITE_API_BASE_URL` | 接口基础路径,默认 `/api` |
|
||||||
|
|
||||||
|
## URL 查询参数
|
||||||
|
|
||||||
|
宿主 App 打开 H5 时,查询参数与 `token`、`lang`、`theme` 同级挂在入口 URL 上。路由跳转请用 `useRouteWithPreferences().linkTo(path)`,会**保留当前整条 query**(含 `clientId`、`accountId`、`token` 等)。
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `token` | 是(调接口) | 登录态;URL 带入后会写入 `localStorage` 供请求头使用(见 `src/utils/token.ts`) |
|
||||||
|
| `clientId` | 是(交易相关接口) | 客户 ID;**仅自 URL 解析**,不入 `localStorage`,存 Pinia(`src/config/urlContext.ts`) |
|
||||||
|
| `accountId` | 是(交易相关接口) | 账户 ID;规则同 `clientId` |
|
||||||
|
| `lang` | 否 | 界面语言,见下表;缺省 `tw` |
|
||||||
|
| `theme` | 否 | 深浅色,见下表;缺省 `dark` |
|
||||||
|
|
||||||
|
`clientId` / `accountId` 在 `App.vue` 监听 `route.query` 时同步到 `useAuthStore`;`src/api/http.ts` 对 **POST** 请求体会自动合并二者(与 Order-H5 交易接口一致)。创建定期投资等操作需同时具备 `token` 与账户上下文。
|
||||||
|
|
||||||
|
语言与主题**仅通过 URL** 控制(不写 `localStorage`)。解析与写入见 `src/config/urlPreferences.ts`;切换语言/主题请使用 `useUrlPreferences()`(`router.replace` 更新 query)。
|
||||||
|
|
||||||
|
### 语言 `lang`(别名 `locale`)
|
||||||
|
|
||||||
|
| URL 取值 | 界面语言 | 内部 i18n |
|
||||||
|
|----------|----------|-----------|
|
||||||
|
| `zh` | 简体中文 | `zh-CN` |
|
||||||
|
| `tw` | 繁体中文 | `zh-TW` |
|
||||||
|
| `en` | English | `en-US` |
|
||||||
|
|
||||||
|
- **缺省**:`tw`(繁体)
|
||||||
|
- 仍兼容旧式 BCP 47 写法(如 `zh-TW`、`zh-CN`、`en-US`),但地址栏写入统一为 `zh` / `tw` / `en`。
|
||||||
|
- Element Plus 文案随当前语言同步(`src/element-plus/locale.ts` + 根节点 `el-config-provider`)。
|
||||||
|
|
||||||
|
### 主题 `theme`
|
||||||
|
|
||||||
|
| 取值 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `dark` / `night` | 深色(缺省) |
|
||||||
|
| `light` / `day` | 浅色 |
|
||||||
|
|
||||||
|
深色模式下为 `html` 增加 `dark` 类,并应用 Element Plus 深色 CSS 变量(`applyTheme` 设置 `data-theme`)。
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
完整入口(本地开发请将 `token` 换成有效值):
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5173/?token=xxx&clientId=2016410736418492416&accountId=2016410907965526016&lang=tw&theme=dark
|
||||||
|
```
|
||||||
|
|
||||||
|
仅调整语言 / 主题时,仍须保留 `token`、`clientId`、`accountId`:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5173/?token=xxx&clientId=2016410736418492416&accountId=2016410907965526016&lang=zh&theme=light
|
||||||
|
http://localhost:5173/?token=xxx&clientId=2016410736418492416&accountId=2016410907965526016&lang=en&theme=dark
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录说明
|
## 目录说明
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `src/api/http.ts` | Axios 实例与拦截器 |
|
| `src/api/` | Axios 实例、拦截器 |
|
||||||
| `src/i18n/` | 国际化配置(简体 / 繁体 / 英文) |
|
| `src/config/` | URL 偏好(`urlPreferences.ts`)、账户上下文(`urlContext.ts`)、`appLocale` 等 |
|
||||||
|
| `src/stores/auth.ts` | `token`、`clientId`、`accountId`(后两者仅来自 URL) |
|
||||||
|
| `src/composables/` | `useUrlPreferences`、`useRouteWithPreferences`、`useBreakpoint` 等 |
|
||||||
|
| `src/i18n/` | vue-i18n 与简体 / 繁体 / 英文文案 |
|
||||||
| `src/router/` | 路由 |
|
| `src/router/` | 路由 |
|
||||||
| `src/stores/` | Pinia 状态 |
|
| `src/stores/` | Pinia 状态 |
|
||||||
| `src/styles/` | 全局 Sass 与变量 |
|
| `src/styles/` | 全局 Sass、断点 mixin、主题变量 |
|
||||||
| `src/views/RecurringInvestmentView.vue` | 定期投资首页 |
|
| `src/views/RecurringInvestmentView.vue` | 定期投资首页 |
|
||||||
| `src/element-plus/locale.ts` | Element Plus 语言与 URL `lang` 映射 |
|
| `src/element-plus/locale.ts` | Element Plus 与 `AppLocale` 映射 |
|
||||||
| `src/types/components.d.ts` | 组件自动导入类型(构建生成) |
|
| `src/types/components.d.ts` | 组件自动导入类型(构建生成) |
|
||||||
|
| `docs/RESPONSIVE_SPEC.md` | PC / 移动端响应式规范 |
|
||||||
|
|
||||||
### Element Plus
|
### Element Plus
|
||||||
|
|
||||||
- 通过 `unplugin-vue-components` + `unplugin-auto-import` 按需引入组件。
|
- `unplugin-vue-components` + `unplugin-auto-import` 按需引入组件与 API。
|
||||||
- 根节点 `el-config-provider` 语言随 URL `lang` 同步(简/繁/英)。
|
- 勿在业务中绕过 store 直接改 `i18n.global.locale` 或主题;应走 URL query 同步链路(见 `.cursor/rules/vue-sfc-patterns.mdc`)。
|
||||||
- 深色模式:`theme=dark` 时为 `html` 增加 `dark` 类,并加载 Element Plus 深色 CSS 变量。
|
|
||||||
|
|
||||||
## 环境变量
|
|
||||||
|
|
||||||
开发环境见 `.env.development`:`VITE_API_BASE_URL` 为接口基础路径(默认 `/api`)。
|
|
||||||
|
|
||||||
## URL 参数(语言与主题)
|
|
||||||
|
|
||||||
语言与主题**仅通过 URL 查询参数**控制(不再使用 `localStorage`)。
|
|
||||||
|
|
||||||
| 参数 | 取值 | 缺省 |
|
|
||||||
|------|------|------|
|
|
||||||
| `lang` | `zh-TW` / `zh-CN` / `en-US`(亦支持 `locale`) | `zh-TW` |
|
|
||||||
| `theme` | `dark` / `light`(亦支持 `night` / `day`) | `dark` |
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
- `http://localhost:5173/?lang=zh-TW&theme=dark`
|
|
||||||
- `http://localhost:5173/?lang=en-US&theme=light`
|
|
||||||
|
|
||||||
顶栏切换语言或主题时会同步更新当前地址栏中的 `lang`、`theme`。
|
|
||||||
|
|
||||||
## PC / 移动端适配
|
## PC / 移动端适配
|
||||||
|
|
||||||
开发与评审须遵循 **[docs/RESPONSIVE_SPEC.md](./docs/RESPONSIVE_SPEC.md)**(断点、布局壳层、Sass mixin、`useBreakpoint`、Element Plus 栅格与表单约定)。
|
开发与评审须遵循 **[docs/RESPONSIVE_SPEC.md](./docs/RESPONSIVE_SPEC.md)**(断点、布局壳层、Sass mixin、`useBreakpoint`、Element Plus 栅格与表单约定)。
|
||||||
|
|
||||||
Cursor 已配置项目规则:`.cursor/rules/`(核心约定、响应式、Vue SFC 写法)。
|
Cursor 项目规则:`.cursor/rules/`(核心约定、响应式、Vue SFC 写法)。
|
||||||
|
|||||||
+24
-1
@@ -69,7 +69,7 @@
|
|||||||
## 补充:URL accountId / clientId
|
## 补充:URL accountId / clientId
|
||||||
|
|
||||||
- 入口 URL 需携带 `clientId`、`accountId`(与 `token`、`lang`、`theme` 同级 query)。
|
- 入口 URL 需携带 `clientId`、`accountId`(与 `token`、`lang`、`theme` 同级 query)。
|
||||||
- 示例:`?token=xxx&clientId=2016410736418492416&accountId=2016410907965526016&lang=zh-TW&theme=dark`
|
- 示例:`?token=xxx&clientId=2016410736418492416&accountId=2016410907965526016&lang=tw&theme=dark`(详见 `README.md`)
|
||||||
- 值仅存于 Pinia(来自 URL,不写 localStorage);`useRouteWithPreferences().linkTo` 会保留 query。
|
- 值仅存于 Pinia(来自 URL,不写 localStorage);`useRouteWithPreferences().linkTo` 会保留 query。
|
||||||
- `src/api/http.ts` 对 POST 请求体自动合并 `clientId`、`accountId`(与 Order-H5 交易接口一致)。
|
- `src/api/http.ts` 对 POST 请求体自动合并 `clientId`、`accountId`(与 Order-H5 交易接口一致)。
|
||||||
|
|
||||||
@@ -202,3 +202,26 @@
|
|||||||
- 路由仅保留 `/`(定期投资);移除 `echarts` 与图表相关 CSS/i18n。
|
- 路由仅保留 `/`(定期投资);移除 `echarts` 与图表相关 CSS/i18n。
|
||||||
- 金额输入 placeholder 国际化为 `recurring.amountPlaceholder`。
|
- 金额输入 placeholder 国际化为 `recurring.amountPlaceholder`。
|
||||||
- 验证:`npm run build`。
|
- 验证:`npm run build`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:README 与 URL `lang` 短码(2026-10-10)
|
||||||
|
|
||||||
|
- **目标**:完善 `README.md`;`lang` 查询参数文档与写入统一为 `zh` / `tw` / `en`(缺省 `tw`)。
|
||||||
|
- **代码**:`resolveLocaleFromLangParam` / `localeToUrlLang`;兼容读取 `zh-CN`、`zh-TW`、`en-US`。
|
||||||
|
- **验证**:`npm run build`;参见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:Vite 可局域网访问(2026-10-10)
|
||||||
|
|
||||||
|
- `vite.config.ts`:`server` / `preview` 增加 `host: true`、`allowedHosts: true`。
|
||||||
|
- `README.md` 开发节补充 Network 访问说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:CT 时间偏移下拉(2026-10-10)
|
||||||
|
|
||||||
|
- 创建表单在选定交易所与资产后请求 `POST /trade/schedule-investment/options/ct-time`(`productCode` 与创建计划一致:`productCodeUnique` 或 `code`)。
|
||||||
|
- 偏移字段改为 `el-select`(`displayName` / `offsetMinutes`);未选资产时禁用并提示先选交易所与资产。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充;验证:`npm run build`。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import http from '@/api/http'
|
|||||||
import type { ApiResponse } from '@/types/api'
|
import type { ApiResponse } from '@/types/api'
|
||||||
import type {
|
import type {
|
||||||
ScheduleInvestmentCreateBody,
|
ScheduleInvestmentCreateBody,
|
||||||
|
ScheduleInvestmentCtTimeOptionsData,
|
||||||
|
ScheduleInvestmentCtTimeOptionsRequest,
|
||||||
ScheduleInvestmentExchangeItem,
|
ScheduleInvestmentExchangeItem,
|
||||||
ScheduleInvestmentPlanDetailData,
|
ScheduleInvestmentPlanDetailData,
|
||||||
ScheduleInvestmentPlanDetailRequest,
|
ScheduleInvestmentPlanDetailRequest,
|
||||||
@@ -19,6 +21,13 @@ export function getScheduleInvestmentExchanges(): Promise<
|
|||||||
return http.post('/trade/schedule-investment/exchanges', {})
|
return http.post('/trade/schedule-investment/exchanges', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 定期投资 CT 时间偏移选项(依赖交易所与产品代码) */
|
||||||
|
export function getScheduleInvestmentCtTimeOptions(
|
||||||
|
body: ScheduleInvestmentCtTimeOptionsRequest,
|
||||||
|
): Promise<ApiResponse<ScheduleInvestmentCtTimeOptionsData>> {
|
||||||
|
return http.post('/trade/schedule-investment/options/ct-time', body)
|
||||||
|
}
|
||||||
|
|
||||||
/** 创建定期投资计划 */
|
/** 创建定期投资计划 */
|
||||||
export function createScheduleInvestmentPlan(
|
export function createScheduleInvestmentPlan(
|
||||||
body: ScheduleInvestmentCreateBody,
|
body: ScheduleInvestmentCreateBody,
|
||||||
|
|||||||
@@ -2,8 +2,48 @@ export const APP_LOCALES = ['zh-CN', 'zh-TW', 'en-US'] as const
|
|||||||
|
|
||||||
export type AppLocale = (typeof APP_LOCALES)[number]
|
export type AppLocale = (typeof APP_LOCALES)[number]
|
||||||
|
|
||||||
|
/** URL `lang` 查询参数取值(写入地址栏时使用) */
|
||||||
|
export const URL_LANG_CODES = ['zh', 'tw', 'en'] as const
|
||||||
|
|
||||||
|
export type UrlLangCode = (typeof URL_LANG_CODES)[number]
|
||||||
|
|
||||||
export const DEFAULT_LOCALE: AppLocale = 'zh-TW'
|
export const DEFAULT_LOCALE: AppLocale = 'zh-TW'
|
||||||
|
|
||||||
|
export const DEFAULT_URL_LANG: UrlLangCode = 'tw'
|
||||||
|
|
||||||
|
export const LOCALE_TO_URL_LANG: Record<AppLocale, UrlLangCode> = {
|
||||||
|
'zh-CN': 'zh',
|
||||||
|
'zh-TW': 'tw',
|
||||||
|
'en-US': 'en',
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_LANG_TO_LOCALE: Record<UrlLangCode, AppLocale> = {
|
||||||
|
zh: 'zh-CN',
|
||||||
|
tw: 'zh-TW',
|
||||||
|
en: 'en-US',
|
||||||
|
}
|
||||||
|
|
||||||
export function isAppLocale(value: string | null): value is AppLocale {
|
export function isAppLocale(value: string | null): value is AppLocale {
|
||||||
return value !== null && (APP_LOCALES as readonly string[]).includes(value)
|
return value !== null && (APP_LOCALES as readonly string[]).includes(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析 URL / 用户输入的语言参数为内部 `AppLocale` */
|
||||||
|
export function resolveLocaleFromLangParam(value: string | undefined): AppLocale {
|
||||||
|
if (!value) {
|
||||||
|
return DEFAULT_LOCALE
|
||||||
|
}
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const lower = trimmed.toLowerCase()
|
||||||
|
if ((URL_LANG_CODES as readonly string[]).includes(lower)) {
|
||||||
|
return URL_LANG_TO_LOCALE[lower as UrlLangCode]
|
||||||
|
}
|
||||||
|
const normalized = trimmed.replace('_', '-')
|
||||||
|
if (isAppLocale(normalized)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localeToUrlLang(locale: AppLocale): UrlLangCode {
|
||||||
|
return LOCALE_TO_URL_LANG[locale]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LocationQuery } from 'vue-router'
|
import type { LocationQuery } from 'vue-router'
|
||||||
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from '@/config/appLocale'
|
import { localeToUrlLang, resolveLocaleFromLangParam, type AppLocale } from '@/config/appLocale'
|
||||||
import { APP_THEMES, DEFAULT_THEME, type AppTheme } from '@/theme/types'
|
import { APP_THEMES, DEFAULT_THEME, type AppTheme } from '@/theme/types'
|
||||||
|
|
||||||
export const URL_PARAM_LANG = 'lang'
|
export const URL_PARAM_LANG = 'lang'
|
||||||
@@ -37,14 +37,7 @@ function normalizeTheme(value: string | undefined): AppTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocale(value: string | undefined): AppLocale {
|
function normalizeLocale(value: string | undefined): AppLocale {
|
||||||
if (!value) {
|
return resolveLocaleFromLangParam(value)
|
||||||
return DEFAULT_LOCALE
|
|
||||||
}
|
|
||||||
const normalized = value.replace('_', '-')
|
|
||||||
if (isAppLocale(normalized)) {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
return DEFAULT_LOCALE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePreferencesFromSearch(search: string): AppPreferences {
|
export function parsePreferencesFromSearch(search: string): AppPreferences {
|
||||||
@@ -82,7 +75,7 @@ export function preferencesToQuery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next[URL_PARAM_LANG] = preferences.locale
|
next[URL_PARAM_LANG] = localeToUrlLang(preferences.locale)
|
||||||
next[URL_PARAM_THEME] = preferences.theme
|
next[URL_PARAM_THEME] = preferences.theme
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ export default {
|
|||||||
SUN: 'Sunday',
|
SUN: 'Sunday',
|
||||||
},
|
},
|
||||||
ctOffsetLabel: 'Trading time offset (minutes)',
|
ctOffsetLabel: 'Trading time offset (minutes)',
|
||||||
ctOffsetHint: '0–240 minutes relative to market open',
|
ctOffsetHint: 'Offset relative to CT market open',
|
||||||
|
ctOffsetPickAssetHint: 'Select exchange and asset first',
|
||||||
ctOffsetPreview: '{n} min',
|
ctOffsetPreview: '{n} min',
|
||||||
previewInvestmentLabel: 'Each investment',
|
previewInvestmentLabel: 'Each investment',
|
||||||
previewQuantity: '{n} shares',
|
previewQuantity: '{n} shares',
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ export default {
|
|||||||
SUN: '星期日',
|
SUN: '星期日',
|
||||||
},
|
},
|
||||||
ctOffsetLabel: '交易时间偏移(分钟)',
|
ctOffsetLabel: '交易时间偏移(分钟)',
|
||||||
ctOffsetHint: '范围 0–240,相对开市时间的偏移',
|
ctOffsetHint: '相对 CT 开市时间的偏移',
|
||||||
|
ctOffsetPickAssetHint: '请先选择交易所与资产',
|
||||||
ctOffsetPreview: '{n} 分钟',
|
ctOffsetPreview: '{n} 分钟',
|
||||||
previewInvestmentLabel: '每次投资',
|
previewInvestmentLabel: '每次投资',
|
||||||
previewQuantity: '{n} 股',
|
previewQuantity: '{n} 股',
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ export default {
|
|||||||
SUN: '星期日',
|
SUN: '星期日',
|
||||||
},
|
},
|
||||||
ctOffsetLabel: '交易時間偏移(分鐘)',
|
ctOffsetLabel: '交易時間偏移(分鐘)',
|
||||||
ctOffsetHint: '範圍 0–240,相對開市時間的偏移',
|
ctOffsetHint: '相對 CT 開市時間的偏移',
|
||||||
|
ctOffsetPickAssetHint: '請先選擇交易所與資產',
|
||||||
ctOffsetPreview: '{n} 分鐘',
|
ctOffsetPreview: '{n} 分鐘',
|
||||||
previewInvestmentLabel: '每次投資',
|
previewInvestmentLabel: '每次投資',
|
||||||
previewQuantity: '{n} 股',
|
previewQuantity: '{n} 股',
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ export interface ScheduleInvestmentExchangeItem {
|
|||||||
tradeDate: string
|
tradeDate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /trade/schedule-investment/options/ct-time` 请求体 */
|
||||||
|
export interface ScheduleInvestmentCtTimeOptionsRequest {
|
||||||
|
exchangeCode: string
|
||||||
|
productCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CT 时间偏移单条选项 */
|
||||||
|
export interface ScheduleInvestmentCtTimeOption {
|
||||||
|
displayName: string
|
||||||
|
offsetMinutes: number
|
||||||
|
submitTime: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /trade/schedule-investment/options/ct-time` 响应 data */
|
||||||
|
export interface ScheduleInvestmentCtTimeOptionsData {
|
||||||
|
ctStartTime: string | null
|
||||||
|
options: ScheduleInvestmentCtTimeOption[]
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /trade/schedule-investment/plan/page` 查询条件(筛选项可传空,仅用分页) */
|
/** `POST /trade/schedule-investment/plan/page` 查询条件(筛选项可传空,仅用分页) */
|
||||||
export interface ScheduleInvestmentPlanPageRequest {
|
export interface ScheduleInvestmentPlanPageRequest {
|
||||||
current: number
|
current: number
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AppPage from '@/components/layout/AppPage.vue'
|
|||||||
import AssetChipPicker from '@/components/recurring/AssetChipPicker.vue'
|
import AssetChipPicker from '@/components/recurring/AssetChipPicker.vue'
|
||||||
import {
|
import {
|
||||||
createScheduleInvestmentPlan,
|
createScheduleInvestmentPlan,
|
||||||
|
getScheduleInvestmentCtTimeOptions,
|
||||||
getScheduleInvestmentExchanges,
|
getScheduleInvestmentExchanges,
|
||||||
getScheduleInvestmentPlanDetail,
|
getScheduleInvestmentPlanDetail,
|
||||||
getScheduleInvestmentPlanPage,
|
getScheduleInvestmentPlanPage,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
type FrequencyType,
|
type FrequencyType,
|
||||||
type InvestmentType,
|
type InvestmentType,
|
||||||
type OrderPriceType,
|
type OrderPriceType,
|
||||||
|
type ScheduleInvestmentCtTimeOption,
|
||||||
type ScheduleInvestmentExchangeItem,
|
type ScheduleInvestmentExchangeItem,
|
||||||
type ScheduleInvestmentNextExecutionData,
|
type ScheduleInvestmentNextExecutionData,
|
||||||
type ScheduleInvestmentPlanDetailPlan,
|
type ScheduleInvestmentPlanDetailPlan,
|
||||||
@@ -101,6 +103,8 @@ const terminateSubmitting = ref(false)
|
|||||||
const nextExecutionPreviewVisible = ref(false)
|
const nextExecutionPreviewVisible = ref(false)
|
||||||
const nextExecutionPreviewLoading = ref(false)
|
const nextExecutionPreviewLoading = ref(false)
|
||||||
const nextExecutionPreview = ref<ScheduleInvestmentNextExecutionData | null>(null)
|
const nextExecutionPreview = ref<ScheduleInvestmentNextExecutionData | null>(null)
|
||||||
|
const ctOffsetOptions = ref<ScheduleInvestmentCtTimeOption[]>([])
|
||||||
|
const ctOffsetLoading = ref(false)
|
||||||
|
|
||||||
const PLAN_PAGE_SIZE = 50
|
const PLAN_PAGE_SIZE = 50
|
||||||
|
|
||||||
@@ -203,6 +207,12 @@ const showFrequencyDay = computed(
|
|||||||
() => form.frequencyType === 'WEEKLY' || form.frequencyType === 'MONTHLY',
|
() => form.frequencyType === 'WEEKLY' || form.frequencyType === 'MONTHLY',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ctOffsetSelectEnabled = computed(() => ctOffsetOptions.value.length > 0)
|
||||||
|
|
||||||
|
const ctOffsetHintText = computed(() =>
|
||||||
|
ctOffsetSelectEnabled.value ? t('recurring.ctOffsetHint') : t('recurring.ctOffsetPickAssetHint'),
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => form.noEnd,
|
() => form.noEnd,
|
||||||
(checked) => {
|
(checked) => {
|
||||||
@@ -257,6 +267,65 @@ watch(picked, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clearCtOffsetOptions() {
|
||||||
|
ctOffsetOptions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCtOffsetAfterOptionsLoad() {
|
||||||
|
if (!ctOffsetOptions.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const allowed = new Set(ctOffsetOptions.value.map((item) => item.offsetMinutes))
|
||||||
|
if (!allowed.has(form.ctOffsetMinutes)) {
|
||||||
|
form.ctOffsetMinutes = ctOffsetOptions.value[0].offsetMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCtOffsetOptions(exchangeCode: string, productCode: string) {
|
||||||
|
ctOffsetLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getScheduleInvestmentCtTimeOptions({ exchangeCode, productCode })
|
||||||
|
ctOffsetOptions.value = Array.isArray(res.data?.options) ? res.data.options : []
|
||||||
|
syncCtOffsetAfterOptionsLoad()
|
||||||
|
} catch {
|
||||||
|
ctOffsetOptions.value = []
|
||||||
|
} finally {
|
||||||
|
ctOffsetLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePickedProductCode(): string {
|
||||||
|
const asset = picked.value[0]
|
||||||
|
if (!asset) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return asset.productCodeUnique?.trim() || asset.code.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [form.exchangeCode, resolvePickedProductCode()] as const,
|
||||||
|
([exchangeCode, productCode]) => {
|
||||||
|
if (!exchangeCode || !productCode) {
|
||||||
|
clearCtOffsetOptions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadCtOffsetOptions(exchangeCode, productCode)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function isCtOffsetValid(offset: number): boolean {
|
||||||
|
if (ctOffsetOptions.value.length) {
|
||||||
|
return ctOffsetOptions.value.some((item) => item.offsetMinutes === offset)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
offset !== null &&
|
||||||
|
offset !== undefined &&
|
||||||
|
!Number.isNaN(offset) &&
|
||||||
|
offset >= 0 &&
|
||||||
|
offset <= 240
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function mapSimpleStockToAsset(item: SimpleStockItem): InvestAsset {
|
function mapSimpleStockToAsset(item: SimpleStockItem): InvestAsset {
|
||||||
const type: AssetType = item.productType === '1' ? 'stock' : 'etf'
|
const type: AssetType = item.productType === '1' ? 'stock' : 'etf'
|
||||||
const engName = item.engName?.trim() ?? ''
|
const engName = item.engName?.trim() ?? ''
|
||||||
@@ -458,14 +527,7 @@ function validate(): boolean {
|
|||||||
fieldError.frequencyDay = true
|
fieldError.frequencyDay = true
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
const offset = form.ctOffsetMinutes
|
if (!isCtOffsetValid(form.ctOffsetMinutes)) {
|
||||||
if (
|
|
||||||
offset === null ||
|
|
||||||
offset === undefined ||
|
|
||||||
Number.isNaN(offset) ||
|
|
||||||
offset < 0 ||
|
|
||||||
offset > 240
|
|
||||||
) {
|
|
||||||
fieldError.ctOffsetMinutes = true
|
fieldError.ctOffsetMinutes = true
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
@@ -499,14 +561,7 @@ function validateNextExecutionPreview(): boolean {
|
|||||||
fieldError.frequencyDay = true
|
fieldError.frequencyDay = true
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
const offset = form.ctOffsetMinutes
|
if (!isCtOffsetValid(form.ctOffsetMinutes)) {
|
||||||
if (
|
|
||||||
offset === null ||
|
|
||||||
offset === undefined ||
|
|
||||||
Number.isNaN(offset) ||
|
|
||||||
offset < 0 ||
|
|
||||||
offset > 240
|
|
||||||
) {
|
|
||||||
fieldError.ctOffsetMinutes = true
|
fieldError.ctOffsetMinutes = true
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
@@ -578,7 +633,8 @@ function resetForm() {
|
|||||||
form.limitPrice = ''
|
form.limitPrice = ''
|
||||||
form.frequencyType = ''
|
form.frequencyType = ''
|
||||||
form.frequencyDay = ''
|
form.frequencyDay = ''
|
||||||
form.ctOffsetMinutes = 30
|
form.ctOffsetMinutes = 0
|
||||||
|
clearCtOffsetOptions()
|
||||||
form.endMode = 'date'
|
form.endMode = 'date'
|
||||||
form.endDate = ''
|
form.endDate = ''
|
||||||
form.noEnd = false
|
form.noEnd = false
|
||||||
@@ -1018,7 +1074,7 @@ const previewOffset = computed(() =>
|
|||||||
<div class="plan-table__asset-ellipsis">
|
<div class="plan-table__asset-ellipsis">
|
||||||
<span class="code">{{ plan.assets[0]?.code }}</span>
|
<span class="code">{{ plan.assets[0]?.code }}</span>
|
||||||
<template v-if="planAssetDisplayName(plan)">
|
<template v-if="planAssetDisplayName(plan)">
|
||||||
{{ planAssetDisplayName(plan) }}
|
({{ planAssetDisplayName(plan) }})
|
||||||
</template>
|
</template>
|
||||||
<span v-if="extraAssetCount(plan)" class="microlabel">
|
<span v-if="extraAssetCount(plan)" class="microlabel">
|
||||||
+{{ extraAssetCount(plan) }}
|
+{{ extraAssetCount(plan) }}
|
||||||
@@ -1275,16 +1331,23 @@ const previewOffset = computed(() =>
|
|||||||
{{ t('recurring.ctOffsetLabel') }}
|
{{ t('recurring.ctOffsetLabel') }}
|
||||||
<span class="req">*</span>
|
<span class="req">*</span>
|
||||||
</label>
|
</label>
|
||||||
<el-input-number
|
<el-select
|
||||||
v-model="form.ctOffsetMinutes"
|
v-model="form.ctOffsetMinutes"
|
||||||
class="control-input control-input-number"
|
class="control-input"
|
||||||
:min="0"
|
filterable
|
||||||
:max="240"
|
:loading="ctOffsetLoading"
|
||||||
:step="1"
|
:disabled="!ctOffsetSelectEnabled"
|
||||||
controls-position="right"
|
:placeholder="t('recurring.ctOffsetLabel')"
|
||||||
@change="fieldError.ctOffsetMinutes = false"
|
@change="fieldError.ctOffsetMinutes = false"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in ctOffsetOptions"
|
||||||
|
:key="option.offsetMinutes"
|
||||||
|
:label="option.displayName"
|
||||||
|
:value="option.offsetMinutes"
|
||||||
/>
|
/>
|
||||||
<div class="hint">{{ t('recurring.ctOffsetHint') }}</div>
|
</el-select>
|
||||||
|
<div class="hint">{{ ctOffsetHintText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
open: true,
|
open: true,
|
||||||
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/base': {
|
'/base': {
|
||||||
target: proxyTarget,
|
target: proxyTarget,
|
||||||
@@ -55,6 +57,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
preview: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
scss: {
|
scss: {
|
||||||
|
|||||||
Reference in New Issue
Block a user