From 833990b2b59b29bcde70401dc598f8dd5cb9dddd Mon Sep 17 00:00:00 2001 From: justin Date: Sat, 10 Oct 2026 13:06:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20CT=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E9=80=89=E6=8B=A9=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20URL=20=E8=AF=AD=E8=A8=80=E5=A4=84=E7=90=86=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=8E=B7=E5=8F=96=20CT=20=E6=97=B6=E9=97=B4=E5=81=8F?= =?UTF-8?q?=E7=A7=BB=E9=80=89=E9=A1=B9=E6=8E=A5=E5=8F=A3=EF=BC=8C=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E6=94=B9=E7=94=A8=E4=B8=8B=E6=8B=89=E9=80=89=E6=8B=A9?= =?UTF-8?q?=EF=BC=9BURL=20=E8=AF=AD=E8=A8=80=E5=8F=82=E6=95=B0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BA=20zh/tw/en=20=E5=B9=B6=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=97=A7=E7=89=88=EF=BC=9BVite=20=E6=94=AF=E6=8C=81=E5=B1=80?= =?UTF-8?q?=E5=9F=9F=E7=BD=91=E8=AE=BF=E9=97=AE=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=96=87=E6=A1=A3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/project-core.mdc | 2 +- IMPACT_ANALYSIS.md | 100 ++++++++++++++++++++++ README.md | 112 ++++++++++++++++++------- TASK_SUMMARY.md | 25 +++++- src/api/scheduleInvestment.ts | 9 ++ src/config/appLocale.ts | 40 +++++++++ src/config/urlPreferences.ts | 13 +-- src/i18n/locales/en-US.ts | 3 +- src/i18n/locales/zh-CN.ts | 3 +- src/i18n/locales/zh-TW.ts | 3 +- src/types/scheduleInvestment.ts | 19 +++++ src/views/RecurringInvestmentView.vue | 115 ++++++++++++++++++++------ vite.config.ts | 7 ++ 13 files changed, 379 insertions(+), 72 deletions(-) diff --git a/.cursor/rules/project-core.mdc b/.cursor/rules/project-core.mdc index 225805d..1a5a6f7 100644 --- a/.cursor/rules/project-core.mdc +++ b/.cursor/rules/project-core.mdc @@ -11,7 +11,7 @@ alwaysApply: true ## 语言与主题(仅 URL) - 查询参数:`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)。 - 路由跳转保留 query:`useRouteWithPreferences().linkTo(path)` 或等价 `{ path, query: route.query }`。 - i18n:`src/i18n/`(简/繁/英);Element Plus 语言:`getElementPlusLocale` + 根节点 `el-config-provider`。 diff --git a/IMPACT_ANALYSIS.md b/IMPACT_ANALYSIS.md index 6fd3911..8c8c4c7 100644 --- a/IMPACT_ANALYSIS.md +++ b/IMPACT_ANALYSIS.md @@ -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) ### 1. 改动概览 @@ -606,3 +640,69 @@ - `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;同网段可打开页面。 diff --git a/README.md b/README.md index db95484..76a49ec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # 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 ``` +默认开发地址:`http://localhost:5173/`。`vite.config.ts` 已设置 `server.host: true`,启动后终端会打印 **Network** 地址,同网段设备可用本机 IP 访问(如 `http://192.168.x.x:5173/`);经内网穿透或自定义域名访问时依赖 `allowedHosts: true`。 + +未带查询参数时,语言为繁体(`lang=tw`)、主题为深色(`theme=dark`)。 + ## 构建 ```bash -npm run build -npm run preview +npm run build # vue-tsc + vite build +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/i18n/` | 国际化配置(简体 / 繁体 / 英文) | +| `src/api/` | Axios 实例、拦截器 | +| `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/stores/` | Pinia 状态 | -| `src/styles/` | 全局 Sass 与变量 | +| `src/styles/` | 全局 Sass、断点 mixin、主题变量 | | `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` | 组件自动导入类型(构建生成) | +| `docs/RESPONSIVE_SPEC.md` | PC / 移动端响应式规范 | ### Element Plus -- 通过 `unplugin-vue-components` + `unplugin-auto-import` 按需引入组件。 -- 根节点 `el-config-provider` 语言随 URL `lang` 同步(简/繁/英)。 -- 深色模式:`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`。 +- `unplugin-vue-components` + `unplugin-auto-import` 按需引入组件与 API。 +- 勿在业务中绕过 store 直接改 `i18n.global.locale` 或主题;应走 URL query 同步链路(见 `.cursor/rules/vue-sfc-patterns.mdc`)。 ## PC / 移动端适配 开发与评审须遵循 **[docs/RESPONSIVE_SPEC.md](./docs/RESPONSIVE_SPEC.md)**(断点、布局壳层、Sass mixin、`useBreakpoint`、Element Plus 栅格与表单约定)。 -Cursor 已配置项目规则:`.cursor/rules/`(核心约定、响应式、Vue SFC 写法)。 +Cursor 项目规则:`.cursor/rules/`(核心约定、响应式、Vue SFC 写法)。 diff --git a/TASK_SUMMARY.md b/TASK_SUMMARY.md index 2694177..37cb5a5 100644 --- a/TASK_SUMMARY.md +++ b/TASK_SUMMARY.md @@ -69,7 +69,7 @@ ## 补充:URL accountId / clientId - 入口 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。 - `src/api/http.ts` 对 POST 请求体自动合并 `clientId`、`accountId`(与 Order-H5 交易接口一致)。 @@ -202,3 +202,26 @@ - 路由仅保留 `/`(定期投资);移除 `echarts` 与图表相关 CSS/i18n。 - 金额输入 placeholder 国际化为 `recurring.amountPlaceholder`。 - 验证:`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`。 diff --git a/src/api/scheduleInvestment.ts b/src/api/scheduleInvestment.ts index a2baba6..508be65 100644 --- a/src/api/scheduleInvestment.ts +++ b/src/api/scheduleInvestment.ts @@ -2,6 +2,8 @@ import http from '@/api/http' import type { ApiResponse } from '@/types/api' import type { ScheduleInvestmentCreateBody, + ScheduleInvestmentCtTimeOptionsData, + ScheduleInvestmentCtTimeOptionsRequest, ScheduleInvestmentExchangeItem, ScheduleInvestmentPlanDetailData, ScheduleInvestmentPlanDetailRequest, @@ -19,6 +21,13 @@ export function getScheduleInvestmentExchanges(): Promise< return http.post('/trade/schedule-investment/exchanges', {}) } +/** 定期投资 CT 时间偏移选项(依赖交易所与产品代码) */ +export function getScheduleInvestmentCtTimeOptions( + body: ScheduleInvestmentCtTimeOptionsRequest, +): Promise> { + return http.post('/trade/schedule-investment/options/ct-time', body) +} + /** 创建定期投资计划 */ export function createScheduleInvestmentPlan( body: ScheduleInvestmentCreateBody, diff --git a/src/config/appLocale.ts b/src/config/appLocale.ts index 3b1e7a7..0ed0512 100644 --- a/src/config/appLocale.ts +++ b/src/config/appLocale.ts @@ -2,8 +2,48 @@ export const APP_LOCALES = ['zh-CN', 'zh-TW', 'en-US'] as const 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_URL_LANG: UrlLangCode = 'tw' + +export const LOCALE_TO_URL_LANG: Record = { + 'zh-CN': 'zh', + 'zh-TW': 'tw', + 'en-US': 'en', +} + +const URL_LANG_TO_LOCALE: Record = { + zh: 'zh-CN', + tw: 'zh-TW', + en: 'en-US', +} + export function isAppLocale(value: string | null): value is AppLocale { 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] +} diff --git a/src/config/urlPreferences.ts b/src/config/urlPreferences.ts index 9a67dea..cef369d 100644 --- a/src/config/urlPreferences.ts +++ b/src/config/urlPreferences.ts @@ -1,5 +1,5 @@ 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' export const URL_PARAM_LANG = 'lang' @@ -37,14 +37,7 @@ function normalizeTheme(value: string | undefined): AppTheme { } function normalizeLocale(value: string | undefined): AppLocale { - if (!value) { - return DEFAULT_LOCALE - } - const normalized = value.replace('_', '-') - if (isAppLocale(normalized)) { - return normalized - } - return DEFAULT_LOCALE + return resolveLocaleFromLangParam(value) } 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 return next } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index f9d7eac..53e50d3 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -105,7 +105,8 @@ export default { SUN: 'Sunday', }, 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', previewInvestmentLabel: 'Each investment', previewQuantity: '{n} shares', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 84c7a12..9481830 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -105,7 +105,8 @@ export default { SUN: '星期日', }, ctOffsetLabel: '交易时间偏移(分钟)', - ctOffsetHint: '范围 0–240,相对开市时间的偏移', + ctOffsetHint: '相对 CT 开市时间的偏移', + ctOffsetPickAssetHint: '请先选择交易所与资产', ctOffsetPreview: '{n} 分钟', previewInvestmentLabel: '每次投资', previewQuantity: '{n} 股', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index c1b1ba0..ef9722f 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -105,7 +105,8 @@ export default { SUN: '星期日', }, ctOffsetLabel: '交易時間偏移(分鐘)', - ctOffsetHint: '範圍 0–240,相對開市時間的偏移', + ctOffsetHint: '相對 CT 開市時間的偏移', + ctOffsetPickAssetHint: '請先選擇交易所與資產', ctOffsetPreview: '{n} 分鐘', previewInvestmentLabel: '每次投資', previewQuantity: '{n} 股', diff --git a/src/types/scheduleInvestment.ts b/src/types/scheduleInvestment.ts index 8e5831d..4b950a3 100644 --- a/src/types/scheduleInvestment.ts +++ b/src/types/scheduleInvestment.ts @@ -25,6 +25,25 @@ export interface ScheduleInvestmentExchangeItem { 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` 查询条件(筛选项可传空,仅用分页) */ export interface ScheduleInvestmentPlanPageRequest { current: number diff --git a/src/views/RecurringInvestmentView.vue b/src/views/RecurringInvestmentView.vue index 2405b94..65ae898 100644 --- a/src/views/RecurringInvestmentView.vue +++ b/src/views/RecurringInvestmentView.vue @@ -6,6 +6,7 @@ import AppPage from '@/components/layout/AppPage.vue' import AssetChipPicker from '@/components/recurring/AssetChipPicker.vue' import { createScheduleInvestmentPlan, + getScheduleInvestmentCtTimeOptions, getScheduleInvestmentExchanges, getScheduleInvestmentPlanDetail, getScheduleInvestmentPlanPage, @@ -36,6 +37,7 @@ import { type FrequencyType, type InvestmentType, type OrderPriceType, + type ScheduleInvestmentCtTimeOption, type ScheduleInvestmentExchangeItem, type ScheduleInvestmentNextExecutionData, type ScheduleInvestmentPlanDetailPlan, @@ -101,6 +103,8 @@ const terminateSubmitting = ref(false) const nextExecutionPreviewVisible = ref(false) const nextExecutionPreviewLoading = ref(false) const nextExecutionPreview = ref(null) +const ctOffsetOptions = ref([]) +const ctOffsetLoading = ref(false) const PLAN_PAGE_SIZE = 50 @@ -203,6 +207,12 @@ const showFrequencyDay = computed( () => 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( () => form.noEnd, (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 { const type: AssetType = item.productType === '1' ? 'stock' : 'etf' const engName = item.engName?.trim() ?? '' @@ -458,14 +527,7 @@ function validate(): boolean { fieldError.frequencyDay = true ok = false } - const offset = form.ctOffsetMinutes - if ( - offset === null || - offset === undefined || - Number.isNaN(offset) || - offset < 0 || - offset > 240 - ) { + if (!isCtOffsetValid(form.ctOffsetMinutes)) { fieldError.ctOffsetMinutes = true ok = false } @@ -499,14 +561,7 @@ function validateNextExecutionPreview(): boolean { fieldError.frequencyDay = true ok = false } - const offset = form.ctOffsetMinutes - if ( - offset === null || - offset === undefined || - Number.isNaN(offset) || - offset < 0 || - offset > 240 - ) { + if (!isCtOffsetValid(form.ctOffsetMinutes)) { fieldError.ctOffsetMinutes = true ok = false } @@ -578,7 +633,8 @@ function resetForm() { form.limitPrice = '' form.frequencyType = '' form.frequencyDay = '' - form.ctOffsetMinutes = 30 + form.ctOffsetMinutes = 0 + clearCtOffsetOptions() form.endMode = 'date' form.endDate = '' form.noEnd = false @@ -1018,7 +1074,7 @@ const previewOffset = computed(() =>
{{ plan.assets[0]?.code }} +{{ extraAssetCount(plan) }} @@ -1275,16 +1331,23 @@ const previewOffset = computed(() => {{ t('recurring.ctOffsetLabel') }} * - -
{{ t('recurring.ctOffsetHint') }}
+ > + + +
{{ ctOffsetHintText }}
diff --git a/vite.config.ts b/vite.config.ts index 5298209..5052f0d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,8 +30,10 @@ export default defineConfig(({ mode }) => { }, }, server: { + host: true, port: 5173, open: true, + allowedHosts: true, proxy: { '/base': { target: proxyTarget, @@ -55,6 +57,11 @@ export default defineConfig(({ mode }) => { }, }, }, + preview: { + host: true, + port: 5173, + allowedHosts: true, + }, css: { preprocessorOptions: { scss: {