Initial commit: G3FO recurring investment schedule frontend
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`(断点、布局、禁止事项以该文档为准)。
|
||||||
@@ -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` 横滚。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
description: Vue 单文件组件与本仓库写法
|
||||||
|
globs: src/**/*.vue
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vue SFC 约定
|
||||||
|
|
||||||
|
## 结构
|
||||||
|
|
||||||
|
- 路由页:`views/*` 根节点使用 `<AppPage>`;标题/导语可用 `.app-page__title`、`.app-page__lead`。
|
||||||
|
- 样式:`<style scoped lang="scss">`;需要断点时用 `respond-up` / `respond-down` mixin。
|
||||||
|
- 路径别名:`@/` → `src/`。
|
||||||
|
|
||||||
|
## 导航与 URL 参数
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouteWithPreferences } from '@/composables/useRouteWithPreferences'
|
||||||
|
const { linkTo } = useRouteWithPreferences()
|
||||||
|
</script>
|
||||||
|
<RouterLink :to="linkTo('/')">...</RouterLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 表单示例(响应式)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||||
|
const { isMobile } = useBreakpoint()
|
||||||
|
</script>
|
||||||
|
<el-form :label-position="isMobile ? 'top' : 'right'">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 不要复制 PC/H5 两套页面文件。
|
||||||
|
- 不要绕过 URL 直接改 `i18n.global.locale` 或 `applyTheme`(应走 store + query 同步链路)。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 开发环境 API:留空则请求同源路径,由 vite proxy 转发到 VITE_API_PROXY_TARGET
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://192.168.3.230:18000
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# Impact Analysis Report
|
||||||
|
|
||||||
|
## 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:按 V5 demo `hkex-trade/recurring-investment.html` 落地「定期投资」页面(计划列表、创建表单、确认弹窗、终止计划),作为应用首页。
|
||||||
|
- **涉及模块**:路由首页、顶栏导航、i18n、全局主题色、定期投资视图/组件/Store。
|
||||||
|
- **改动类型**:功能新增 + 视觉令牌对齐。
|
||||||
|
|
||||||
|
## 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `src/router/index.ts` | `/` 由 `HomeView` 改为 `RecurringInvestmentView`;`meta.titleKey` 改为 `recurring.title`。 |
|
||||||
|
| `src/stores/recurringPlans.ts` | 新增 `addPlan` / `cancelPlan` / `runningCount`;内存示例数据,无接口。 |
|
||||||
|
| `src/views/RecurringInvestmentView.vue` | 新增列表、表单校验、确认弹窗、移动端卡片。 |
|
||||||
|
| `src/components/recurring/AssetChipPicker.vue` | 新增资产晶片多选与搜索下拉。 |
|
||||||
|
| `src/composables` / `useUrlPreferences` | 未改;语言主题仍仅走 URL query。 |
|
||||||
|
| `src/styles/main.scss` / `variables.scss` | 主色与语义色对齐 demo(`--color-primary` 等);新增 `--color-up/down/warn` 等令牌。 |
|
||||||
|
| `src/i18n/locales/*` | 新增 `recurring.*` 文案(繁/简/英)。 |
|
||||||
|
| `src/components/AppHeader.vue` | 已自 `App.vue` 移除引用并删除组件文件;壳层不再展示顶栏标题与导航。 |
|
||||||
|
| `src/App.vue` | 去掉 `<AppHeader />`,页面内容由 `RouterView` 直接渲染。 |
|
||||||
|
| `src/styles/element-plus-theme.scss` | 新增:对齐 `--el-*` 输入/选择框 hover、focus 与 `--color-*` 令牌(含 `html.dark` 下 primary RGB)。 |
|
||||||
|
|
||||||
|
与原逻辑差异:首页不再展示 ECharts 示例(`HomeView.vue` 仍保留,未挂路由)。表单提交仅写入 Pinia 内存,不调用后端。
|
||||||
|
|
||||||
|
## 3. 调用方与影响范围分析
|
||||||
|
|
||||||
|
- **路由 `/`**:所有默认入口进入定期投资页。
|
||||||
|
- **顶栏**:`common.home` 不再作为首页标签;关于页路由不变。
|
||||||
|
- **主题变量**:`--color-bg` / `--color-muted` / `--color-primary` 变化会影响顶栏与关于页观感(非接口行为)。
|
||||||
|
- **破坏性变更**:否(无对外 API;旧首页组件文件仍在仓库中)。
|
||||||
|
|
||||||
|
## 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。纯前端示例页,计划数据刷新即丢失。
|
||||||
|
- **主要风险**:移动端表格改为卡片,与 demo 桌面表有布局差异(符合本仓库响应式规范)。
|
||||||
|
- **回滚**:将 `/` 指回 `HomeView.vue`,并还原主题变量与顶栏文案。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
## 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`(vue-tsc + Vite)通过。
|
||||||
|
- 浏览器冒烟:列表展示、资产搜索选择、校验、确认建立、终止计划、语言/主题 URL 切换、移动端卡片。
|
||||||
|
|
||||||
|
## 配置变更
|
||||||
|
|
||||||
|
无环境变量或配置中心变更。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:Token、开发代理与服务端多语言(2025-10-08)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:与 Order-H5 一致,支持 URL `token` 写入请求头;本地开发代理到 `192.168.3.230:18000`;通过 `getAllList` 加载服务端多语言。
|
||||||
|
- **涉及模块**:`vite.config.ts`、`src/api/http.ts`、`src/stores/auth.ts`、`src/stores/serviceI18n.ts`、`src/api/base.ts`、`src/api/scheduleInvestment.ts`、`src/main.ts`、`src/App.vue`。
|
||||||
|
- **改动类型**:配置变更 + 基础设施。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `vite.config.ts` | `/base`、`/trade` 代理至 `VITE_API_PROXY_TARGET`(默认 `http://192.168.3.230:18000`)。 |
|
||||||
|
| `.env.development` | `VITE_API_BASE_URL` 置空;新增 `VITE_API_PROXY_TARGET`。 |
|
||||||
|
| `src/api/http.ts` | 请求头 `token`、`Accept-Language`;统一 `code === '0'` 成功处理。 |
|
||||||
|
| `src/utils/token.ts` / `stores/auth.ts` | URL 优先解析 token 并持久化(localStorage)。 |
|
||||||
|
| `stores/serviceI18n.ts` | 拉取 `/base/systemI18n/getAllList` 并按 zh-CN/zh-TW/en-US 映射。 |
|
||||||
|
| `src/api/scheduleInvestment.ts` | 封装 `POST /trade/schedule-investment/exchanges`。 |
|
||||||
|
| `src/main.ts` / `App.vue` | 启动与 query 变化时加载服务端 i18n(有 token 时)。 |
|
||||||
|
|
||||||
|
语言/主题仍仅 URL 控制,未使用 localStorage。
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 所有经 `http` 的请求需有效 token(与后端约定一致)。
|
||||||
|
- `useServiceI18n().si18n(id)` 供后续对接接口返回的 `*I18nId` 字段。
|
||||||
|
- **破坏性变更**:否(定期投资页仍用本地 vue-i18n 文案)。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低;无 token 时多语言接口可能失败,前端仍可用本地 messages。
|
||||||
|
- **回滚**:还原 `vite.config.ts`、`.env.development` 与 `http.ts` 拦截器。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build` 通过。
|
||||||
|
|
||||||
|
### 配置变更(本节)
|
||||||
|
|
||||||
|
| 配置项 | 含义 | Dev 默认 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `VITE_API_PROXY_TARGET` | Vite 代理目标主机 | `http://192.168.3.230:18000` |
|
||||||
|
| `VITE_API_BASE_URL` | Axios baseURL,空则同源 | 空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:URL accountId / clientId
|
||||||
|
|
||||||
|
- **模块**:`src/config/urlContext.ts`、`stores/auth.ts`、`api/http.ts`、`composables/useAccountContext.ts`、`App.vue`
|
||||||
|
- **行为**:从 query 解析 `clientId`、`accountId`;POST 体自动附带;仅 URL 来源,不持久化 localStorage
|
||||||
|
- **破坏性变更**:否
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单交易所下拉(2025-10-08)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:在「创建定期投资」表单中,于「資產」字段上方增加交易所代码下拉,数据来自 `POST /api/admin/exchange/getSelectItemList`。
|
||||||
|
- **涉及模块**:`src/api/exchange.ts`、`src/views/RecurringInvestmentView.vue`、`src/components/recurring/AssetChipPicker.vue`、`vite.config.ts`、i18n。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `src/api/exchange.ts` | 新增 `getExchangeSelectItemList()`。 |
|
||||||
|
| `vite.config.ts` | 开发代理增加 `/api` → `VITE_API_PROXY_TARGET`。 |
|
||||||
|
| `RecurringInvestmentView.vue` | 挂载时拉取交易所列表;`isEnabled === false` 的项不展示;必填校验;切换交易所清空已选资产;未选交易所时禁用资产选择器。 |
|
||||||
|
| `AssetChipPicker.vue` | 支持 `disabled` 属性。 |
|
||||||
|
| `src/i18n/locales/*` | 新增 `recurring.exchange`。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 创建表单提交仍写 Pinia 内存,`exchangeCode` 暂未写入 `RecurringPlanDraft`(待对接创建 API 时扩展)。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低;接口需登录 token,失败时下拉为空且表单校验拦截。
|
||||||
|
- **回滚**:移除交易所字段与 `exchange.ts`,还原 `vite.config.ts` 代理项。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build` 通过。
|
||||||
|
|
||||||
|
### 配置变更(本节)
|
||||||
|
|
||||||
|
| 配置项 | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| Vite `server.proxy['/api']` | 转发 admin 等 `/api/*` 接口至后端 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:资产列表对接 getSimpleStockPage(2025-10-08)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:创建表单在选定交易所后,从 `POST /product/product/getSimpleStockPage` 拉取可投标的,请求体 `exchangeCodes` 为交易所下拉选中值;支持 `searchName` 与 `isUpdatedToday: true`。
|
||||||
|
- **涉及模块**:`src/api/product.ts`、`vite.config.ts`(`/product` 代理)、`RecurringInvestmentView.vue`、`AssetChipPicker.vue`、`InvestAsset` 类型、i18n。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `src/api/product.ts` | 新增 `getSimpleStockPage`、`extractSimpleStockRecords`。 |
|
||||||
|
| `vite.config.ts` | 开发代理增加 `/product`。 |
|
||||||
|
| `RecurringInvestmentView.vue` | 交易所变更时请求标的列表;搜索框防抖 300ms 带 `searchName` 重查;映射 `productCode`/`localName`/`productKey` 等到 `InvestAsset`。 |
|
||||||
|
| `AssetChipPicker.vue` | `loading`、`search` 事件。 |
|
||||||
|
| `investAssets.ts` | 不再作为创建表单数据源(文件保留)。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 资产选择依赖 token 与产品服务可用性;失败时列表为空。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低;`size: 100000` 单次体量较大,需关注后端与网络耗时。
|
||||||
|
- **回滚**:恢复 `INVEST_ASSETS` 与旧 `AssetChipPicker` 用法,移除 `/product` 代理。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:交易所与资产并排布局(2025-10-08)
|
||||||
|
|
||||||
|
- **模块**:`RecurringInvestmentView.vue` 表单栅格与 `.field--exchange` / `.field--asset` 样式。
|
||||||
|
- **行为**:`md+` 同一行各 `12` 列平分;`xs` 仍为两行堆叠。交易所下拉在该行内 `max-width: none` 铺满列宽。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:资产选择器单选与控件样式对齐(2025-10-08)
|
||||||
|
|
||||||
|
- **模块**:`AssetChipPicker.vue`。
|
||||||
|
- **行为**:资产仅能选一项(新选替换旧选);未选交易所时展示与 `el-select` 一致的「請選擇」占位文案与 `--color-muted` 色;控件高度与边框/背景对齐 Element Plus 默认输入框(32px、`--color-control-*`)。
|
||||||
|
- **调用方**:`RecurringInvestmentView.vue` 仍使用 `InvestAsset[]`,最多一项。
|
||||||
|
- **破坏性变更**:否(原多选 UI 改为单选,业务表单语义仍为单资产创建)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:定期投资页标题/标签字号加大(2026-10-08)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:表头「資產」、表单标题「創建定期投資」、字段标签「交易所 *」、结束方式切换「期數」等文字偏小,提升可读性。
|
||||||
|
- **涉及模块**:`src/views/RecurringInvestmentView.vue` 样式。
|
||||||
|
- **改动类型**:视觉调整(无逻辑变更)。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `.panel-head h2` / `.panel-head--notes h2` | `13px` → `16px`(含「創建定期投資」「我的計劃」等区块标题)。 |
|
||||||
|
| `.plan-table th` | `10px` → `13px`,字距略收(`0.12em` → `0.08em`)。 |
|
||||||
|
| `.field label` | `10.5px` → `13px`,字距略收(`0.1em` → `0.06em`)。 |
|
||||||
|
| `.end-toggle button` | `13px` → `15px`。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅本页 scoped 样式;表格表头、表单标签、区块标题、结束方式按钮同步变大。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是(还原上述字号即可)。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- 浏览器核对桌面列表表头、创建表单标题/标签/期数切换按钮字号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单对齐 schedule-investment 入参(2026-10-08)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:创建定期投资表单字段与交易接口入参一致(投资类型、价格类型、定投周期/执行日、结束类型、交易时间偏移等);`clientId`/`accountId` 仍由 `http` 拦截器从 Pinia 合并。
|
||||||
|
- **涉及模块**:`RecurringInvestmentView.vue`、`src/types/scheduleInvestment.ts`、`src/utils/scheduleInvestmentPayload.ts`、`src/api/scheduleInvestment.ts`、`src/types/recurringInvestment.ts`、i18n。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| 表单 | 新增 `investmentType`、`orderPriceType`、`frequencyType`、`frequencyDay`、`ctOffsetMinutes`;按金额/数量切换输入;周期仅 DAILY/WEEKLY/MONTHLY。 |
|
||||||
|
| `buildScheduleInvestmentCreateBody` | 将表单映射为 `ScheduleInvestmentCreateBody`(含 `endType` DATE/COUNT/NONE、`totalExecutionCount`、`productCode`)。 |
|
||||||
|
| `createScheduleInvestmentPlan` | `POST /trade/schedule-investment/plan/create`;确认建立时调用,成功后仍写入本地示例列表。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 创建流程依赖 URL `token`、`clientId`、`accountId` 及后端 create 接口可用。
|
||||||
|
- **破坏性变更**:否(列表仍用本地 Pinia;表单原「间隔」细粒度选项改为与接口一致的三种周期)。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:中(create 路径或字段名若与后端不一致需调整 API 封装)。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`;表单校验与确认弹窗预览。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单控件宽度统一(≥768px 为 70%)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:宽屏下 `el-select` 被 `max-width: 340px` 限制,而 `el-input`、资产选择框为 100% 列宽,视觉不一致;统一为平板及以上宽度占字段区域 70%,移动端仍为 100%。
|
||||||
|
- **涉及模块**:`RecurringInvestmentView.vue`(`.form-body` 样式)。
|
||||||
|
- **改动类型**:缺陷修复 / UI。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `.form-body` | 新增 CSS 变量 `--form-control-width`:`100%`(默认)、`respond-up(md)` 时为 `70%`。 |
|
||||||
|
| `.control-*` / `.end-toggle` / `.asset-wrap` | 移除 `max-width: 340px`,统一使用 `--form-control-width`。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅定期投资创建表单面板内控件宽度;无脚本与 API 变更。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是(还原样式块即可)。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- 宽屏(≥768px)下交易所、资产、金额/数量、下拉、日期、结束方式切换条宽度一致为 70%;窄屏仍为全宽。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建定投接口路径更正
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:后端创建计划路径为 `POST /trade/schedule-investment/plan/create`,与前端原 `/trade/schedule-investment/create` 不一致导致 404。
|
||||||
|
- **涉及模块**:`src/api/scheduleInvestment.ts`。
|
||||||
|
- **改动类型**:缺陷修复。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `createScheduleInvestmentPlan` | URL 改为 `/trade/schedule-investment/plan/create`;入参 `ScheduleInvestmentCreateBody` 不变。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 调用方:`RecurringInvestmentView.vue` 确认建立流程。
|
||||||
|
- **破坏性变更**:否(仅修正路径)。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- 带 `token`、`clientId`、`accountId` 提交创建;确认网络请求路径与响应 `code === 0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:交易所下拉改用 schedule-investment/exchanges
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:创建表单交易所选项改为 `POST /trade/schedule-investment/exchanges`,不再调用 `/base/exchange/getAllExchanges`。
|
||||||
|
- **涉及模块**:`scheduleInvestment.ts`、`scheduleInvestment.ts`(类型)、`RecurringInvestmentView.vue`;删除 `src/api/exchange.ts`。
|
||||||
|
- **改动类型**:缺陷修复 / 接口对齐。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `getScheduleInvestmentExchanges` | 返回类型 `ScheduleInvestmentExchangeItem[]`(`exchangeCode`、`nameI18nId`、`tradeDate`)。 |
|
||||||
|
| `loadExchangeOptions` | 调用上述接口;展示文案仍经 `si18n(nameI18nId)`。 |
|
||||||
|
| `exchange.ts` | 已删除。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅定期投资页交易所下拉;资产搜索仍用 `getSimpleStockPage` + `exchangeCodes`。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低(若 `data` 非数组需再适配分页结构)。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- 打开创建表单,交易所下拉有数据且标签为 i18n 名称;切换后资产列表正常加载。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:我的计划列表对接 plan/page
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:「我的定期投資計劃」由 `POST /trade/schedule-investment/plan/page` 拉取,仅传分页 `current`/`size`,其余筛选项传空字符串;移除 Pinia 内存示例数据。
|
||||||
|
- **涉及模块**:`scheduleInvestment.ts`(API/类型)、`scheduleInvestmentPlanMapper.ts`、`recurringPlans.ts`、`RecurringInvestmentView.vue`、i18n。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `getScheduleInvestmentPlanPage` | `current`+`size` 及空筛选字段请求;映射 `records` → `RecurringPlan`。 |
|
||||||
|
| `loadPlans` | 挂载与创建成功后刷新列表;`runningCount` 按非终止状态统计。 |
|
||||||
|
| `recurringPlans` store | 去掉 `addPlan` 与种子数据,改为 `setPlans`。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 列表展示金额/数量、周期+执行日、结束日期/期数/无结束;终止按钮仍为本地状态(待对接终止 API)。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低(分页暂取第一页 `size=50`;若 `current` 须从 0 起需再对齐)。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- 进入页面请求 `plan/page`;创建成功后列表刷新;`ACTIVE` 计入进行中数量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建入参字段 qtyPerExecution
|
||||||
|
|
||||||
|
- **改动**:`ScheduleInvestmentCreateBody` 与 `buildScheduleInvestmentCreateBody` 中按数量定投字段由 `quantityPerExecution` 改为 `qtyPerExecution`,与接口及列表 `records` 一致。
|
||||||
|
- **破坏性变更**:否(仅请求 JSON 字段名)。
|
||||||
|
- **风险**:低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:限价单 limitPrice
|
||||||
|
|
||||||
|
- **改动**:`orderPriceType === 'LIMIT'` 时表单展示「限价」输入,校验 `> 0`,创建请求体附带 `limitPrice`;市价单不传该字段。
|
||||||
|
- **涉及**:`RecurringInvestmentView.vue`、`scheduleInvestmentPayload.ts`、`ScheduleInvestmentCreateBody`、i18n。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
- **风险**:低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建计划 productEngName / productLocalName
|
||||||
|
|
||||||
|
- **改动**:`getSimpleStockPage` 映射保留 `engName`、`localName`;`buildScheduleInvestmentCreateBody` 写入 `productEngName`、`productLocalName`。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
- **风险**:低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划列表「详情」弹窗(2026-10-09)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:我的计划列表「操作」列增加「详情」,调用 `POST /trade/schedule-investment/plan/detail`(`planNo`)弹窗展示计划要点与最近执行记录。
|
||||||
|
- **涉及模块**:`src/api/scheduleInvestment.ts`、`src/types/scheduleInvestment.ts`、`RecurringInvestmentView.vue`、i18n(zh-TW / zh-CN / en-US)。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `getScheduleInvestmentPlanDetail` | 新增详情接口封装。 |
|
||||||
|
| 类型 `ScheduleInvestmentPlanDetailData` 等 | 描述 `plan` + `recentExecutions` 响应结构。 |
|
||||||
|
| `openPlanDetail` / 详情 `el-dialog` | 加载详情、格式化周期/结束/投资方式等字段;执行记录表格或空态。 |
|
||||||
|
| 列表操作列 / 移动卡片 | 「详情」与「终止」并列。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 依赖列表项 `planNo`(来自 `plan/page` 映射);无 `planNo` 时前端提示,不发起请求。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低(只读查询;失败由 http 拦截器提示)。
|
||||||
|
- **回滚方式是否简单**:是(移除按钮与 API 封装即可)。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`;列表点击详情,核对弹窗字段与空执行记录文案。
|
||||||
|
|
||||||
|
### 补充(2026-10-09)
|
||||||
|
|
||||||
|
- 详情弹窗不再展示「渠道」;「交易所」通过 `getScheduleInvestmentExchanges` 缓存与创建表单相同逻辑(`si18n(nameI18nId)`)反显文案,无匹配时回退 `exchangeCode`。
|
||||||
|
- 产品行 `productLocalName` / `productEngName`:`en-US` 优先英文,简繁优先本地名(`investAssetDisplay.ts`);列表映射保留双语字段,切换 URL `lang` 后名称随语言更新。
|
||||||
|
- 计划「終止」:`el-dialog` 二次确认 + 选填 `terminateReason`,调用 `POST /trade/schedule-investment/plan/terminate`(`planNo`),成功后 `loadPlans` 刷新列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单「预览下次执行」(2026-10-09)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:创建计划表单底部增加「预览下次执行」,调用 `POST /trade/schedule-investment/plan/next-execution`,在弹窗展示节假日调整后的执行日与时点。
|
||||||
|
- **涉及模块**:`src/api/scheduleInvestment.ts`、`src/types/scheduleInvestment.ts`、`RecurringInvestmentView.vue`、i18n(zh-TW / zh-CN / en-US)。
|
||||||
|
- **改动类型**:功能新增。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `previewScheduleInvestmentNextExecution` | 新增 next-execution 接口封装。 |
|
||||||
|
| `ScheduleInvestmentNextExecutionRequest` / `Data` | 描述请求与响应字段。 |
|
||||||
|
| `validateNextExecutionPreview` / `openNextExecutionPreview` | 校验交易所、周期、执行日(周/月)、CT 偏移;`startDate` 选填传入。 |
|
||||||
|
| 创建表单 `form-actions` | 增加按钮与预览 `el-dialog`(`ri-preview` 样式)。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅创建表单使用;不改动「继续」全流程校验(仍要求标的、金额等)。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低(只读预览;失败由 http 拦截器提示)。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`;填写交易所与周期后点击预览,核对弹窗字段与 `deferred` 文案。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划列表金额/数量展示(2026-10-09)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:移动卡片按 `investmentType` 区分「金额」「数量」标签;桌面列表表头统一为「金额/数量」。
|
||||||
|
- **涉及模块**:`RecurringInvestmentView.vue`、`src/i18n/locales/*`(`recurring.colAmount`)。
|
||||||
|
- **改动类型**:缺陷修复 / 文案对齐。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `planInvestmentLabel` | 新增:`QUANTITY` → `recurring.quantity`,否则 `recurring.amount`;用于移动卡片 meta 的 `<dt>`。 |
|
||||||
|
| `recurring.colAmount`(三语) | 由单一「金额」改为「金额/数量」(与 `planDetail.colAmount` 一致),用于计划列表表头。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 计划列表移动卡片与桌面表头文案;单元格数值仍由 `planInvestmentDisplay` 输出。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`;列表含 AMOUNT / QUANTITY 计划时核对卡片标签与表头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划列表资产列省略与 Tooltip(2026-10-09)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:桌面计划表「资产」列限制宽度,长名称单行省略,悬停 `el-tooltip` 展示全文。
|
||||||
|
- **涉及模块**:`RecurringInvestmentView.vue`(`planAssetCellText`、表样式 `plan-table__asset-*`)。
|
||||||
|
- **改动类型**:体验优化。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| `planAssetCellText` | 拼接代码、名称与 `+N` 多标的后缀,供 tooltip 与展示一致。 |
|
||||||
|
| `plan-table` | `table-layout: fixed`;资产列宽约 220px;内层 `text-overflow: ellipsis`。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅「我的计划」桌面表格首列;移动卡片不变。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`;长产品名悬停可见完整 tooltip。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:移动计划卡片操作按钮样式(2026-10-09)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:移动卡片「详情 / 终止」与桌面表一致,纯文字链接、无边框。
|
||||||
|
- **涉及模块**:`RecurringInvestmentView.vue`(复用 `detail-link` / `cancel-link`)。
|
||||||
|
- **改动类型**:体验优化。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| 计划卡片 `plan-card__actions` | 按钮类名改为 `detail-link`、`cancel-link`;移除 `plan-card__detail` / `plan-card__cancel` 边框样式。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 仅移动端计划卡片底部操作区。
|
||||||
|
- **破坏性变更**:否。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- 移动断点下核对详情(主色)、终止(跌色)文字与悬停下划线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:清理脚手架示例(2026-10-09)
|
||||||
|
|
||||||
|
### 1. 改动概览
|
||||||
|
|
||||||
|
- **背景与目标**:接口已全部对接,移除未使用的 ECharts 示例、关于页、静态资产列表与相关依赖/文案。
|
||||||
|
- **涉及模块**:路由、视图/组件删除、`i18n`、`main.scss`、`package.json`、文档。
|
||||||
|
- **改动类型**:重构 / 清理。
|
||||||
|
|
||||||
|
### 2. 方法级改动分析
|
||||||
|
|
||||||
|
| 位置 | 差异 |
|
||||||
|
|------|------|
|
||||||
|
| 删除 `SampleChart.vue`、`HomeView.vue`、`AboutView.vue` | 不再保留未挂路由或纯技术栈展示的页面。 |
|
||||||
|
| 删除 `constants/investAssets.ts` | 创建表单资产已由 `getSimpleStockPage` 提供,静态列表无引用。 |
|
||||||
|
| `router/index.ts` | 移除 `/about`;首页路由名改为 `recurring-investment`。 |
|
||||||
|
| `package.json` | 移除 `echarts` 依赖。 |
|
||||||
|
| `main.scss` | 移除仅示例图表使用的 `--chart-height`、`--color-chart-*`。 |
|
||||||
|
| `i18n` | 移除 `home.*`、`common.home`/`common.about`;新增 `recurring.amountPlaceholder`。 |
|
||||||
|
| `RecurringInvestmentView.vue` | 金额输入 placeholder 改为 i18n。 |
|
||||||
|
|
||||||
|
### 3. 调用方与影响范围
|
||||||
|
|
||||||
|
- 无对外 API;用户若曾收藏 `/about` 将 404(本应用此前无入口链至该页)。
|
||||||
|
- **破坏性变更**:否(功能面仅删除未使用示例)。
|
||||||
|
|
||||||
|
### 4. 风险与回滚
|
||||||
|
|
||||||
|
- **风险级别**:低。
|
||||||
|
- **回滚方式是否简单**:是(自 Git 恢复删除文件与依赖)。
|
||||||
|
|
||||||
|
### 5. 验证与测试
|
||||||
|
|
||||||
|
- `npm run build`。
|
||||||
|
- 冒烟:定期投资列表/创建/详情/终止、语言切换。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Schedule Invest Plan
|
||||||
|
|
||||||
|
Vue 3 + TypeScript 前端项目,技术栈:Vite、Vue Router、Pinia、Axios、vue-i18n、Sass、Element Plus。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录说明
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `src/api/http.ts` | Axios 实例与拦截器 |
|
||||||
|
| `src/i18n/` | 国际化配置(简体 / 繁体 / 英文) |
|
||||||
|
| `src/router/` | 路由 |
|
||||||
|
| `src/stores/` | Pinia 状态 |
|
||||||
|
| `src/styles/` | 全局 Sass 与变量 |
|
||||||
|
| `src/views/RecurringInvestmentView.vue` | 定期投资首页 |
|
||||||
|
| `src/element-plus/locale.ts` | Element Plus 语言与 URL `lang` 映射 |
|
||||||
|
| `src/types/components.d.ts` | 组件自动导入类型(构建生成) |
|
||||||
|
|
||||||
|
### 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`。
|
||||||
|
|
||||||
|
## PC / 移动端适配
|
||||||
|
|
||||||
|
开发与评审须遵循 **[docs/RESPONSIVE_SPEC.md](./docs/RESPONSIVE_SPEC.md)**(断点、布局壳层、Sass mixin、`useBreakpoint`、Element Plus 栅格与表单约定)。
|
||||||
|
|
||||||
|
Cursor 已配置项目规则:`.cursor/rules/`(核心约定、响应式、Vue SFC 写法)。
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
# TASK_SUMMARY
|
||||||
|
|
||||||
|
## 任务基本信息
|
||||||
|
|
||||||
|
- **任务名称**:实现定期投资页面(对齐 V5 demo)
|
||||||
|
- **相关项目**:schedule_invest_plan
|
||||||
|
- **改动类型**:功能新增
|
||||||
|
|
||||||
|
## 改动说明
|
||||||
|
|
||||||
|
- 首页改为定期投资:我的计划列表、创建表单(资产晶片搜索、开始日期、金额、间隔、结束日期/期数)、注意事项、确认弹窗、终止计划。
|
||||||
|
- 行为对齐 demo:必填校验、确认后再写入列表、新建状态为「已建立」、终止后状态为「已终止」。
|
||||||
|
- 三语文案(zh-TW / zh-CN / en-US);语言与主题仍仅通过 URL `lang`/`theme` 控制。
|
||||||
|
- 视觉令牌对齐 demo 青绿主色与涨跌语义色;移动端计划列表使用卡片。
|
||||||
|
|
||||||
|
## 影响与风险
|
||||||
|
|
||||||
|
- 参见 `IMPACT_ANALYSIS.md`;无破坏性变更,风险低。
|
||||||
|
- 计划数据仅存 Pinia 内存,刷新页面恢复为示例一条。
|
||||||
|
|
||||||
|
## 测试与验证
|
||||||
|
|
||||||
|
- `npm run build`:通过。
|
||||||
|
- 浏览器:创建/重设/确认/终止、校验失败提示、深浅色与语言切换。
|
||||||
|
|
||||||
|
## 后续事项
|
||||||
|
|
||||||
|
- 对接真实计划查询/创建/终止 API(经 `src/api/http.ts`)。
|
||||||
|
- 资产搜索已对接 `getSimpleStockPage`(交易所 → `exchangeCodes`);创建计划 API 待接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:getSimpleStockPage 资产搜索
|
||||||
|
|
||||||
|
- 交易所下拉选中后请求标的;`exchangeCodes: [exchangeCode]`,`isUpdatedToday: true`。
|
||||||
|
- 晶片搜索框防抖调用同一接口并传 `searchName`。
|
||||||
|
- 映射字段含 `productKey`、`productCodeUnique` 等,便于后续创建计划入参。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:交易所与资产同一行
|
||||||
|
|
||||||
|
- 创建表单在 `md+` 将交易所与资产晶片各占 12 列并排;`xs` 仍上下堆叠。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充(顶栏与表单控件样式)
|
||||||
|
|
||||||
|
- 顶栏 `AppHeader`(含标题「投資計劃排期」)已从壳层移除。
|
||||||
|
- Element Plus 日期、下拉框 hover/focus 边框与背景对齐项目主色与 `--color-control-*`(见 `src/styles/element-plus-theme.scss`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:Token、代理与服务端多语言
|
||||||
|
|
||||||
|
- URL 参数 `token` 写入请求头 `token`(参考 Order-H5);路由跳转保留 query(含 token)。
|
||||||
|
- 开发代理:`/base/*` → `getAllList` 等;`/trade/*` → `schedule-investment/exchanges` 等(目标 `192.168.3.230:18000`,可改 `.env.development`)。
|
||||||
|
- 有 token 时启动加载 `getSystemI18nAllList`;组件内用 `useServiceI18n().si18n('I18N_ID')` 解析服务端文案。
|
||||||
|
- `getScheduleInvestmentExchanges()` 已封装,待业务页面对接。
|
||||||
|
- `npm run build` 通过。
|
||||||
|
|
||||||
|
## 补充:创建表单交易所下拉
|
||||||
|
|
||||||
|
- 在「資產」上方增加必填「交易所」下拉,接口 `POST /api/admin/exchange/getSelectItemList`;选项文案优先 `nameI18nId` + `useServiceI18n`。
|
||||||
|
- 未选择交易所时资产晶片选择器禁用;切换交易所清空已选资产。
|
||||||
|
- 开发环境新增 Vite 代理 `/api`。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
## 补充:URL accountId / clientId
|
||||||
|
|
||||||
|
- 入口 URL 需携带 `clientId`、`accountId`(与 `token`、`lang`、`theme` 同级 query)。
|
||||||
|
- 示例:`?token=xxx&clientId=2016410736418492416&accountId=2016410907965526016&lang=zh-TW&theme=dark`
|
||||||
|
- 值仅存于 Pinia(来自 URL,不写 localStorage);`useRouteWithPreferences().linkTo` 会保留 query。
|
||||||
|
- `src/api/http.ts` 对 POST 请求体自动合并 `clientId`、`accountId`(与 Order-H5 交易接口一致)。
|
||||||
|
|
||||||
|
## 补充:资产晶片单选与样式
|
||||||
|
|
||||||
|
- `AssetChipPicker` 限制为单资产;高度、边框、占位色与交易所等 `el-select`/`el-input` 一致。
|
||||||
|
- 未选交易所时禁用态显示 `recurring.pleaseSelect`,避免 disabled input 占位符发灰不一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:定期投资页文字加大
|
||||||
|
|
||||||
|
- 区块标题 `16px`、表格表头与表单标签 `13px`、结束方式(日期/期数)按钮 `15px`。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单对齐接口字段
|
||||||
|
|
||||||
|
- 新增投资类型(AMOUNT/QUANTITY)、订单价格类型、定投周期与执行日、交易时间偏移(0–240 分钟);提交时组装 `ScheduleInvestmentCreateBody` 并调用 `createScheduleInvestmentPlan`。
|
||||||
|
- `exchangeCode`、资产 `productCode`、`startDate`、结束日期/期数与既有表单项对应;`clientId`/`accountId` 由 HTTP 拦截器注入。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建表单控件宽度统一
|
||||||
|
|
||||||
|
- 平板及以上(`≥768px`)表单项控件宽度统一为列宽的 70%;移动端保持 100%。
|
||||||
|
- 移除原 `340px` 上限,使 `el-select`、`el-input`、`AssetChipPicker`、日期与结束方式切换条一致。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md`「创建表单控件宽度统一」补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建定投 API 路径
|
||||||
|
|
||||||
|
- `createScheduleInvestmentPlan` 使用 `POST /trade/schedule-investment/plan/create`。
|
||||||
|
- 请求体仍由 `buildScheduleInvestmentCreateBody` 生成;`clientId` / `accountId` 由 HTTP 拦截器合并。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:交易所下拉接口
|
||||||
|
|
||||||
|
- 使用 `POST /trade/schedule-investment/exchanges`,字段 `exchangeCode` / `nameI18nId` / `tradeDate`。
|
||||||
|
- 已移除 `src/api/exchange.ts`(原 `getAllExchanges`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划列表 plan/page
|
||||||
|
|
||||||
|
- `POST /trade/schedule-investment/plan/page`,请求仅使用 `current: 1`、`size: 50`,其余筛选字段为空。
|
||||||
|
- 列表字段映射 `productCode`、金额/数量、周期、状态 `ACTIVE` 等;创建成功后 `loadPlans` 刷新。
|
||||||
|
- 终止计划仍为前端演示(待接终止接口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:限价 limitPrice
|
||||||
|
|
||||||
|
- 订单价格类型为「限价」时展示限价输入,校验大于 0,创建请求携带 `limitPrice`;市价不传。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:创建计划产品名称
|
||||||
|
|
||||||
|
- 选股时保留 `engName` / `localName`,创建 body 传 `productEngName`、`productLocalName`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:资产选择展示 productCodeUnique
|
||||||
|
|
||||||
|
- 下拉与晶片展示 `1.HK`(`productCodeUnique`),名称 trim 全角空格;去重键使用 `productKey`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:投资类型与金额/数量合一控件
|
||||||
|
|
||||||
|
- 移除独立「投资类型」切换行;左侧小下拉(按金额/按数量)与右侧输入同一输入框样式一行展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:结束类型合一控件
|
||||||
|
|
||||||
|
- 「特定日期 / 期數」与结束日期或期数输入合并为 `field-combo`;日期模式下保留「无结束日期」勾选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划详情弹窗(2026-10-09)
|
||||||
|
|
||||||
|
- 我的计划表格与移动卡片「操作」增加「详情」;请求 `POST /trade/schedule-investment/plan/detail`(`planNo`)。
|
||||||
|
- 弹窗展示计划编号、交易所、产品、每次投资、订单价格、周期、偏移、起止、下次执行、进度、状态、创建时间、渠道;下方为最近执行记录表或空态。
|
||||||
|
- 三语文案见 `recurring.planDetail.*`;详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
- 详情弹窗隐藏渠道;交易所名称与创建表单下拉同源反显(`exchangeSelectOptions`)。
|
||||||
|
- 产品名称按 `lang` 在 `productLocalName` / `productEngName` 间切换(详情与计划列表一致)。
|
||||||
|
- 终止计划:`el-dialog` 确认 + 选填原因,接口 `plan/terminate`;成功提示并刷新列表(`recurring.terminate*` / `cancelConfirm*`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:预览下次执行(2026-10-09)
|
||||||
|
|
||||||
|
- 创建表单「重设 / 繼續」之间增加「预览下次执行」;请求 `POST /trade/schedule-investment/plan/next-execution`(`exchangeCode`、`frequencyType`、`frequencyDay`、`ctOffsetMinutes`、可选 `startDate`)。
|
||||||
|
- 弹窗展示周期日期、执行日期、执行时间、是否顺延、CT 偏移;文案见 `recurring.previewNextExecution*`。
|
||||||
|
- 校验范围小于「继续」:不要求标的与金额;详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划列表金额/数量展示(2026-10-09)
|
||||||
|
|
||||||
|
- 移动计划卡片:`investmentType === 'QUANTITY'` 时 meta 标签为「数量」,否则为「金额」;数值逻辑不变。
|
||||||
|
- 桌面计划列表表头 `recurring.colAmount` 改为「金额/数量」(三语与详情执行记录表头一致)。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:计划列表资产列省略与 Tooltip(2026-10-09)
|
||||||
|
|
||||||
|
- 桌面计划表资产列固定约 220px,超出省略;`el-tooltip` 展示完整代码与名称。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:移动计划卡片操作按钮样式(2026-10-09)
|
||||||
|
|
||||||
|
- 移动卡片「详情 / 终止」复用 `detail-link`、`cancel-link`,无边框纯文字;字号 15px、紧凑内边距(与桌面表操作一致)。
|
||||||
|
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充:清理脚手架示例(2026-10-09)
|
||||||
|
|
||||||
|
- 删除:`SampleChart.vue`、`HomeView.vue`、`AboutView.vue`、`investAssets.ts`、默认 `assets/*.svg`。
|
||||||
|
- 路由仅保留 `/`(定期投资);移除 `echarts` 与图表相关 CSS/i18n。
|
||||||
|
- 金额输入 placeholder 国际化为 `recurring.amountPlaceholder`。
|
||||||
|
- 验证:`npm run build`。
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# PC / 移动端响应式规范
|
||||||
|
|
||||||
|
> 适用项目:`schedule-invest-plan`
|
||||||
|
> 版本:v1.0
|
||||||
|
> 策略:**Mobile First(移动优先)** + 单代码库适配 PC 与移动端浏览器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 一套代码 | 不维护独立 H5 仓库,通过断点与布局组件适配 |
|
||||||
|
| 移动优先 | 默认样式面向小屏;使用 `min-width` 媒体查询逐级增强 |
|
||||||
|
| 流式布局 | 宽度优先 `%` / `fr` / `max-width`,避免写死大屏像素宽度 |
|
||||||
|
| 触控友好 | 可点击区域最小 **44×44px**(含按钮、图标按钮) |
|
||||||
|
| 与 Element Plus 一致 | 栅格、表单 `label-position`、对话框宽度遵循 EP 响应式用法 |
|
||||||
|
| 主题无关 | 深浅色只改 CSS 变量,不改变断点与间距阶梯 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 视口与设备范围
|
||||||
|
|
||||||
|
- **最小支持宽度**:320px(`body` 已设置 `min-width: 320px`)
|
||||||
|
- **主要适配对象**:
|
||||||
|
- 移动端:竖屏手机(320–767px)
|
||||||
|
- 平板:768–1023px
|
||||||
|
- PC:≥1024px
|
||||||
|
- **HTML**:保留 `viewport` meta,禁止用户缩放锁死(不设 `user-scalable=no`),以保证无障碍与系统字体缩放。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 断点定义(统一来源)
|
||||||
|
|
||||||
|
断点数值以 **`src/constants/breakpoints.ts`** 为 JS/TS 唯一真相;SCSS 使用 **`src/styles/breakpoints.scss`**(数值须与 TS 保持一致)。
|
||||||
|
|
||||||
|
| 令牌 | 宽度 | 典型场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `sm` | ≥576px | 大屏手机横屏、小平板 |
|
||||||
|
| `md` | ≥768px | 平板竖屏 |
|
||||||
|
| `lg` | ≥1024px | 笔记本 / PC 主内容区 |
|
||||||
|
| `xl` | ≥1280px | 宽屏,内容区达到 `max-width` |
|
||||||
|
|
||||||
|
### 3.1 语义化设备类型(业务判断用)
|
||||||
|
|
||||||
|
在脚本中通过 **`useBreakpoint()`** 获取(勿在各页面重复 `resize` 监听):
|
||||||
|
|
||||||
|
| 字段 | 条件 | 用途示例 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `isMobile` | 宽度 < 768px | 表单单列、`label-position="top"` |
|
||||||
|
| `isTablet` | 768px ≤ 宽度 < 1024px | 双列栅格 |
|
||||||
|
| `isDesktop` | 宽度 ≥ 1024px | 侧栏 + 主内容 |
|
||||||
|
| `isWide` | 宽度 ≥ 1280px | 宽表、多列看板 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 布局规范
|
||||||
|
|
||||||
|
### 4.1 页面壳层
|
||||||
|
|
||||||
|
```
|
||||||
|
#app
|
||||||
|
└── .app-shell (min-height: 100vh)
|
||||||
|
└── .app-container (max-width + 水平居中 + 内边距)
|
||||||
|
├── AppHeader
|
||||||
|
└── AppPage / 页面内容
|
||||||
|
```
|
||||||
|
|
||||||
|
- **内容最大宽度**:`1280px`(`--layout-max-width`)
|
||||||
|
- **页面内边距**:使用 CSS 变量,禁止在业务页写死 `padding: 32px`:
|
||||||
|
|
||||||
|
| 变量 | 移动端(<768) | 平板(≥768) | PC(≥1024) |
|
||||||
|
|------|------------------|--------------|--------------|
|
||||||
|
| `--page-padding-x` | 16px | 24px | 32px |
|
||||||
|
| `--page-padding-y` | 16px | 24px | 32px |
|
||||||
|
|
||||||
|
### 4.2 页面组件
|
||||||
|
|
||||||
|
- 路由级视图根节点使用 **`AppPage`**(`src/components/layout/AppPage.vue`),类名 `app-page`。
|
||||||
|
- 需要全宽区块(如顶栏下横幅)时,在 `AppPage` 内再包一层 `.app-page__section`,通过负 margin 拉满(见组件注释,慎用)。
|
||||||
|
|
||||||
|
### 4.3 Element Plus 栅格
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-row :gutter="gutter">
|
||||||
|
<el-col :xs="24" :sm="12" :lg="8">...</el-col>
|
||||||
|
</el-row>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **gutter**:移动端 `12`,PC `20`(可用 `useBreakpoint().isMobile` 计算)。
|
||||||
|
- 默认单列:`xs="24"`,再按 `sm` / `md` / `lg` 递增列数。
|
||||||
|
|
||||||
|
### 4.4 表单
|
||||||
|
|
||||||
|
| 场景 | 规范 |
|
||||||
|
|------|------|
|
||||||
|
| 移动端 | `label-position="top"`,`size="default"` 或 `large` |
|
||||||
|
| PC | `label-position="right"`,`label-width` 统一(如 `100px`) |
|
||||||
|
| 按钮区 | 移动端主按钮 `width: 100%` 或 `el-button` 块级排列 |
|
||||||
|
|
||||||
|
### 4.5 表格
|
||||||
|
|
||||||
|
- 移动端:优先 **卡片列表** 或 **横向滚动**(`el-table` 外包 `.app-table-scroll`),避免压缩到不可读。
|
||||||
|
- PC:常规表格;列过多时使用 `fixed` 与 `min-width`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 样式编写规范(Sass)
|
||||||
|
|
||||||
|
### 5.1 媒体查询
|
||||||
|
|
||||||
|
统一使用 **`src/styles/mixins/_respond.scss`** 中的 mixin(已在 Vite 中全局注入):
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.app-card {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
@include respond-up(md) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include respond-up(lg) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar {
|
||||||
|
@include respond-down(md) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mixin | 含义 |
|
||||||
|
|-------|------|
|
||||||
|
| `respond-up($name)` | `min-width`,从该断点起生效 |
|
||||||
|
| `respond-down($name)` | `max-width`,小于该断点生效 |
|
||||||
|
| `respond-between($from, $to)` | 闭开区间,用于仅平板样式 |
|
||||||
|
|
||||||
|
**禁止**在业务组件中手写零散 `@media (max-width: 750px)`,除非规范文档修订并同步 `breakpoints.scss`。
|
||||||
|
|
||||||
|
### 5.2 尺寸与字体
|
||||||
|
|
||||||
|
- 间距使用 **4px 基准**:4、8、12、16、24、32、48。
|
||||||
|
- 正文字号:移动 **14px**,PC **14–16px**(`--font-size-base`)。
|
||||||
|
- 标题:移动比 PC 小一级(如 `h1` 移动 20px,PC 24px)。
|
||||||
|
|
||||||
|
### 5.3 颜色与主题
|
||||||
|
|
||||||
|
- 业务样式只引用 **`var(--color-*)`**,不写死 `#fff` / `#000`(图表等特殊场景除外)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 脚本与组合式 API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||||
|
|
||||||
|
const { isMobile, isDesktop, width } = useBreakpoint()
|
||||||
|
```
|
||||||
|
|
||||||
|
- 仅当 **布局结构** 或 **组件属性** 需要切换时使用(如 `label-position`)。
|
||||||
|
- 不要用 `width` 做业务权限或接口参数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 导航与顶栏
|
||||||
|
|
||||||
|
- **PC**:标题与导航同一行,控件横向排列。
|
||||||
|
- **移动**:标题独占一行;导航与语言/主题控件换行,选择器 **等分或 100% 宽**。
|
||||||
|
- 路由跳转须保留 URL 上的 `lang`、`theme`(`useRouteWithPreferences`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试与验收
|
||||||
|
|
||||||
|
| 类型 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 浏览器 | Chrome 最新 + Safari iOS / Android Chrome 各一 |
|
||||||
|
| 断点抽测 | 375、414、768、1024、1280 宽度无横向滚动条(除表格滚动容器) |
|
||||||
|
| 横竖屏 | 图表与弹层在旋转后 `resize` 正常 |
|
||||||
|
| 无障碍 | 焦点可见;表单控件具备 `aria-label` 或可见 label |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 禁止事项
|
||||||
|
|
||||||
|
- 禁止为 PC / H5 复制两套页面文件(除非产品明确要求独立路由)。
|
||||||
|
- 禁止 `position: fixed` 大面积遮挡而不留安全区(刘海屏注意 `env(safe-area-inset-*)` 后续扩展)。
|
||||||
|
- 禁止在 <768px 使用 hover 作为唯一交互提示。
|
||||||
|
- 禁止未读规范即新增断点数值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 相关文件索引
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/constants/breakpoints.ts` | 断点像素、设备语义 |
|
||||||
|
| `src/composables/useBreakpoint.ts` | 响应式设备状态 |
|
||||||
|
| `src/styles/breakpoints.scss` | Sass 断点 Map |
|
||||||
|
| `src/styles/mixins/_respond.scss` | 媒体查询 mixin |
|
||||||
|
| `src/styles/layout.scss` | 壳层与工具类 |
|
||||||
|
| `src/components/layout/AppPage.vue` | 页面根布局 |
|
||||||
|
| `docs/RESPONSIVE_SPEC.md` | 本文档 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 变更流程
|
||||||
|
|
||||||
|
修改断点或间距阶梯时须:
|
||||||
|
|
||||||
|
1. 同步更新 `breakpoints.ts` 与 `breakpoints.scss`;
|
||||||
|
2. 更新本文档版本号与变更说明;
|
||||||
|
3. 在 `IMPACT_ANALYSIS.md` 中注明是否影响既有页面布局;
|
||||||
|
4. 回归测试 §9 所列宽度。
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-TW" data-theme="dark" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>Recurring Investment</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "schedule-invest-plan",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"element-plus": "^2.14.7",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.42",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"sass": "^1.97.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"unplugin-auto-import": "^21.1.0",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
|
"vite": "^8.3.0",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
File diff suppressed because it is too large
Load Diff
+51
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getElementPlusLocale } from '@/element-plus/locale'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useServiceI18nStore } from '@/stores/serviceI18n'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const serviceI18nStore = useServiceI18nStore()
|
||||||
|
|
||||||
|
const elementLocale = computed(() => getElementPlusLocale(appStore.locale))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
appStore.syncFromRouteQuery(query)
|
||||||
|
authStore.syncContextFromQuery(query)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 首屏由 main.ts 拉取多语言;仅 URL token 变更时再 reload,避免重复请求 */
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
const raw = route.query.token
|
||||||
|
return Array.isArray(raw) ? raw[0] : raw
|
||||||
|
},
|
||||||
|
(token, prevToken) => {
|
||||||
|
if (!token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authStore.setTokenManually(token)
|
||||||
|
if (prevToken !== undefined && token !== prevToken) {
|
||||||
|
serviceI18nStore.reload().catch(() => undefined)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-config-provider :locale="elementLocale">
|
||||||
|
<div class="app-shell">
|
||||||
|
<div class="app-container">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
@@ -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<ApiResponse<SystemI18n[]>> {
|
||||||
|
return http.post('/base/systemI18n/getAllList', {})
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
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
|
||||||
@@ -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<ApiResponse<SimpleStockPageData | SimpleStockItem[]>> {
|
||||||
|
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 : []
|
||||||
|
}
|
||||||
@@ -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<ScheduleInvestmentExchangeItem[]>
|
||||||
|
> {
|
||||||
|
return http.post('/trade/schedule-investment/exchanges', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建定期投资计划 */
|
||||||
|
export function createScheduleInvestmentPlan(
|
||||||
|
body: ScheduleInvestmentCreateBody,
|
||||||
|
): Promise<ApiResponse<unknown>> {
|
||||||
|
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<ScheduleInvestmentPlanPageRequest, 'current' | 'size'> &
|
||||||
|
Partial<Omit<ScheduleInvestmentPlanPageRequest, 'current' | 'size'>>,
|
||||||
|
): Promise<ApiResponse<ScheduleInvestmentPlanPageData>> {
|
||||||
|
return http.post('/trade/schedule-investment/plan/page', {
|
||||||
|
...EMPTY_PLAN_PAGE_FILTERS,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定期投资计划详情(含最近执行记录) */
|
||||||
|
export function getScheduleInvestmentPlanDetail(
|
||||||
|
body: ScheduleInvestmentPlanDetailRequest,
|
||||||
|
): Promise<ApiResponse<ScheduleInvestmentPlanDetailData>> {
|
||||||
|
return http.post('/trade/schedule-investment/plan/detail', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终止定期投资计划 */
|
||||||
|
export function terminateScheduleInvestmentPlan(
|
||||||
|
body: ScheduleInvestmentPlanTerminateRequest,
|
||||||
|
): Promise<ApiResponse<unknown>> {
|
||||||
|
return http.post('/trade/schedule-investment/plan/terminate', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览下次计划执行(节假日调整后的执行日与时点) */
|
||||||
|
export function previewScheduleInvestmentNextExecution(
|
||||||
|
body: ScheduleInvestmentNextExecutionRequest,
|
||||||
|
): Promise<ApiResponse<ScheduleInvestmentNextExecutionData>> {
|
||||||
|
return http.post('/trade/schedule-investment/plan/next-execution', body)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
/** 是否使用更窄的标题区(表单页等) */
|
||||||
|
dense?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="app-page" :class="{ 'app-page--dense': dense }">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-page--dense {
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { InvestAsset } from '@/types/recurringInvestment'
|
||||||
|
import {
|
||||||
|
investAssetDisplayCode,
|
||||||
|
investAssetDisplayName,
|
||||||
|
investAssetOptionKey,
|
||||||
|
resolveAppLocale,
|
||||||
|
} from '@/utils/investAssetDisplay'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
assets: InvestAsset[]
|
||||||
|
modelValue: InvestAsset[]
|
||||||
|
invalid?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: InvestAsset[]]
|
||||||
|
search: [keyword: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
function assetDisplayName(asset: InvestAsset) {
|
||||||
|
return investAssetDisplayName(asset, resolveAppLocale(locale.value))
|
||||||
|
}
|
||||||
|
const query = ref('')
|
||||||
|
const open = ref(false)
|
||||||
|
const wrapRef = ref<HTMLElement | null>(null)
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const pickedCodes = computed(
|
||||||
|
() => new Set(props.modelValue.map((item) => investAssetOptionKey(item))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const keyword = query.value.trim().toLowerCase()
|
||||||
|
return props.assets.filter((item) => {
|
||||||
|
if (pickedCodes.value.has(investAssetOptionKey(item))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const code = investAssetDisplayCode(item).toLowerCase()
|
||||||
|
const name = investAssetDisplayName(item).toLowerCase()
|
||||||
|
const eng = item.engName?.trim().toLowerCase() ?? ''
|
||||||
|
const raw = item.code.toLowerCase()
|
||||||
|
return (
|
||||||
|
code.includes(keyword) ||
|
||||||
|
raw.includes(keyword) ||
|
||||||
|
name.includes(keyword) ||
|
||||||
|
(eng && eng.includes(keyword))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasSelection = computed(() => props.modelValue.length > 0)
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasSelection.value) {
|
||||||
|
open.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(asset: InvestAsset) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', [asset])
|
||||||
|
query.value = ''
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const next = props.modelValue.slice()
|
||||||
|
next.splice(index, 1)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputFocus() {
|
||||||
|
if (!props.disabled) {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Backspace' && !query.value && props.modelValue.length) {
|
||||||
|
removeAt(props.modelValue.length - 1)
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentClick(event: MouseEvent) {
|
||||||
|
if (!wrapRef.value?.contains(event.target as Node)) {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, (value) => {
|
||||||
|
if (!props.disabled) {
|
||||||
|
emit('search', value.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="wrapRef" class="asset-wrap">
|
||||||
|
<div
|
||||||
|
class="asset-box"
|
||||||
|
:class="{ 'asset-box--err': invalid, 'asset-box--disabled': disabled }"
|
||||||
|
@click="focusInput"
|
||||||
|
>
|
||||||
|
<span v-for="(asset, index) in modelValue" :key="investAssetOptionKey(asset)" class="asset-chip">
|
||||||
|
{{ investAssetDisplayCode(asset) }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="asset-chip__remove"
|
||||||
|
:aria-label="t('recurring.removeAsset', { code: investAssetDisplayCode(asset) })"
|
||||||
|
@click.stop="removeAt(index)"
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M2.5 2.5l7 7M9.5 2.5l-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="disabled && !modelValue.length"
|
||||||
|
class="asset-placeholder"
|
||||||
|
>
|
||||||
|
{{ t('recurring.pleaseSelect') }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-else-if="!hasSelection"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="query"
|
||||||
|
class="asset-input"
|
||||||
|
:placeholder="t('recurring.assetPlaceholder')"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="onInputFocus"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="open" class="asset-drop" role="listbox">
|
||||||
|
<button
|
||||||
|
v-for="asset in filtered"
|
||||||
|
:key="investAssetOptionKey(asset)"
|
||||||
|
type="button"
|
||||||
|
class="asset-opt"
|
||||||
|
@click="pick(asset)"
|
||||||
|
>
|
||||||
|
<span class="asset-opt__code">{{ investAssetDisplayCode(asset) }}</span>
|
||||||
|
<span class="asset-opt__name">{{ assetDisplayName(asset) }}</span>
|
||||||
|
<span class="asset-opt__type">{{ t(`recurring.assetType.${asset.type}`) }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="loading" class="asset-none">{{ t('recurring.assetLoading') }}</div>
|
||||||
|
<div v-else-if="!filtered.length" class="asset-none">{{ t('recurring.assetEmpty') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.asset-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-box {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--el-component-size, 32px);
|
||||||
|
padding: 1px 11px;
|
||||||
|
border: 1px solid var(--color-control-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-control-bg);
|
||||||
|
cursor: text;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--err {
|
||||||
|
border-color: var(--color-down);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-down-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: var(--color-control-bg);
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--color-control-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 4px 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-text);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #0a0f16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-chip__remove {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 25%);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
background: rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include respond-down(md) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-drop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 10px 28px rgb(0 0 0 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-opt__code {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-opt__name {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-opt__type {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-primary-ink);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-none {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppPreferences>) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> {
|
||||||
|
const next: Record<string, string> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<AppLocale, Language> = {
|
||||||
|
'zh-CN': zhCn,
|
||||||
|
'zh-TW': zhTw,
|
||||||
|
'en-US': en,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementPlusLocale(locale: AppLocale): Language {
|
||||||
|
return elementPlusLocaleMap[locale]
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_API_PROXY_TARGET?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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: '股票',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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: '股票',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
+35
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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<AppLocale>(initial.locale)
|
||||||
|
const theme = ref<AppTheme>(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<AppPreferences>,
|
||||||
|
existing: LocationQuery,
|
||||||
|
): Record<string, string> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<string | null>(getToken())
|
||||||
|
const clientId = ref<string | null>(null)
|
||||||
|
const accountId = ref<string | null>(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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<RecurringPlan[]>([])
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<AppLocale, ServiceLangKey> = {
|
||||||
|
'zh-CN': 'sc',
|
||||||
|
'zh-TW': 'tc',
|
||||||
|
'en-US': 'en',
|
||||||
|
}
|
||||||
|
|
||||||
|
let reloadingPromise: Promise<void> | null = null
|
||||||
|
const loadingIds: string[] = []
|
||||||
|
|
||||||
|
export const useServiceI18nStore = defineStore('serviceI18n', () => {
|
||||||
|
const maps = ref<Record<ServiceLangKey, Map<string, string>>>({
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
$color-primary: #0e7c7b;
|
||||||
|
$layout-max-width: 1280px;
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
code: string | number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
Vendored
+12
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
Vendored
+34
@@ -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']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})}`
|
||||||
|
}
|
||||||
@@ -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<InvestAsset, 'code' | 'productCodeUnique'>): string {
|
||||||
|
const unique = asset.productCodeUnique?.trim()
|
||||||
|
if (unique) {
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
return asset.code.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function investAssetDisplayName(
|
||||||
|
asset: Pick<InvestAsset, 'name' | 'localName' | 'engName' | 'code'>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<ScheduleFormPayloadInput, 'endMode' | 'noEnd' | 'endDate'>): 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<FrequencyType, IntervalKey> = {
|
||||||
|
DAILY: 'daily',
|
||||||
|
WEEKLY: 'weekly',
|
||||||
|
MONTHLY: 'monthly',
|
||||||
|
}
|
||||||
|
return map[frequencyType]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./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"]
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user