Enhance recurring investment features and UI components:
- Implement restrictions on the start date selection in the creation form to prevent past dates. - Add order status display in the recent execution records for better visibility. - Introduce system code mapping for order statuses, frequency types, and end types, ensuring multi-language support. - Implement pagination for the investment plans list to improve performance and user experience. - Update internationalization files to include new labels for order status and other UI elements. - Document changes in `IMPACT_ANALYSIS.md` and `TASK_SUMMARY.md` for clarity on the updates.
This commit is contained in:
@@ -1085,3 +1085,153 @@
|
||||
### 5. 验证与测试
|
||||
|
||||
- `npm run build`;表单三种结束类型切换与提交 payload `endType` 符合预期。
|
||||
|
||||
---
|
||||
|
||||
## 补充:开始日期禁选过去日 + 详情执行订单状态列(2026-10-14)
|
||||
|
||||
- **背景与目标**:创建计划开始日期不得早于今天;计划详情最近执行列表展示后端 `orderStatus`。
|
||||
- **涉及模块**:`RecurringInvestmentView.vue`、三语言 `planDetail.colOrderStatus`。
|
||||
- **改动类型**:缺陷修复 / 功能优化(UI)。
|
||||
|
||||
### 2. 方法级改动分析
|
||||
|
||||
| 位置 | 差异 |
|
||||
|------|------|
|
||||
| `disableFormStartDate` / `isStartDateBeforeToday` | 按本地日历日禁用/校验开始日期。 |
|
||||
| 创建表单 `el-date-picker`(startDate) | 绑定 `:disabled-date="disableFormStartDate"`。 |
|
||||
| `validate()` | 开始日期为空或早于今天时 `fieldError.startDate`。 |
|
||||
| 详情 `detail-exec-table` / 移动端卡片 | 末列增加 `colOrderStatus` 与 `row.orderStatus`。 |
|
||||
|
||||
### 3. 调用方与影响范围
|
||||
|
||||
- 仅创建表单与计划详情弹窗展示;**破坏性变更**:否。
|
||||
|
||||
### 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。
|
||||
- **回滚方式是否简单**:是。
|
||||
|
||||
### 5. 验证与测试
|
||||
|
||||
- `npm run build`;开始日期面板不可点选昨日;详情执行表末列为订单状态。
|
||||
|
||||
---
|
||||
|
||||
## 补充:订单状态系统码展示(2026-10-14)
|
||||
|
||||
- **背景与目标**:计划详情「最近执行」的 `orderStatus` 为系统码 `codeValue`(如 `12`),需通过 `POST /admin/systemCode/getByCodeTypeBatch`(`param: ['OrderStatus']`)映射 `descriptionI18nId`,并结合已有 `systemI18n` 展示当前语言文案。
|
||||
- **涉及模块**:`src/api/systemCode.ts`、`src/stores/systemCode.ts`、`src/composables/useSystemCode.ts`、`RecurringInvestmentView.vue`。
|
||||
- **改动类型**:功能优化(UI)。
|
||||
|
||||
### 2. 方法级改动分析
|
||||
|
||||
| 位置 | 差异 |
|
||||
|------|------|
|
||||
| `getSystemCodeByCodeTypeBatch` | 新增批量查询系统码 API 封装。 |
|
||||
| `useSystemCodeStore.ensureCodeTypes` | 缓存 `OrderStatus` 等 codeType 的 value→i18nId 映射。 |
|
||||
| `useSystemCode().codeLabel` | 解析 codeValue 为服务端多语言文案。 |
|
||||
| `formatOrderStatus` / 详情执行表与移动端卡片 | 原样展示 `row.orderStatus` 改为 `formatOrderStatus`。 |
|
||||
|
||||
### 3. 调用方与影响范围
|
||||
|
||||
- 仅定期投资计划详情弹窗执行记录「订单状态」列;**破坏性变更**:否(无匹配码时仍回退显示原 codeValue)。
|
||||
|
||||
### 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低(依赖 admin 系统码与 base i18n 接口可用性)。
|
||||
- **回滚方式是否简单**:是。
|
||||
|
||||
### 5. 验证与测试
|
||||
|
||||
- `npm run build`;详情执行记录订单状态显示为 i18n 文案而非裸数字码。
|
||||
|
||||
---
|
||||
|
||||
## 补充:定投周期系统码下拉(2026-10-14)
|
||||
|
||||
- **背景与目标**:创建表单「定投周期」选项来自 `getByCodeTypeBatch` 的 `ScheduleInvestmentFrequencyType`(`DAILY` / `WEEKLY` / `MONTHLY`),展示 `descriptionI18nId` 对应服务端多语言;列表/详情/确认预览的周期文案与表单一致。
|
||||
- **涉及模块**:`systemCode` store/composable、`scheduleInvestment.ts` 常量、`RecurringInvestmentView.vue`、`recurringInvestment.ts`(draft `frequencyType`)。
|
||||
- **改动类型**:功能优化(UI)。
|
||||
|
||||
### 2. 方法级改动分析
|
||||
|
||||
| 位置 | 差异 |
|
||||
|------|------|
|
||||
| `useSystemCodeStore` | 按 `sort` 保留系统码条目,提供 `getSortedCodeValues`。 |
|
||||
| `codeSelectOptions` / `codeLabelWithFallback` | 下拉选项与带本地 i18n 回退的文案解析。 |
|
||||
| `frequencyOptions` | 优先系统码列表,接口失败时回退 `FREQUENCY_TYPE_KEYS` + 静态 i18n。 |
|
||||
| `frequencyTypeLabel` | 替换多处 `t('recurring.frequency.*')`。 |
|
||||
| `onMounted` `ensureCodeTypes` | 增加 `ScheduleInvestmentFrequencyType`。 |
|
||||
|
||||
### 3. 调用方与影响范围
|
||||
|
||||
- 创建表单周期下拉、计划列表/详情/确认弹窗周期展示;**破坏性变更**:否(选项 value 仍为 `DAILY` 等 API 枚举)。
|
||||
|
||||
### 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。
|
||||
- **回滚方式是否简单**:是。
|
||||
|
||||
### 5. 验证与测试
|
||||
|
||||
- `npm run build`;周期下拉为服务端文案;未加载系统码时仍显示静态 i18n。
|
||||
|
||||
---
|
||||
|
||||
## 补充:结束类型系统码下拉(2026-10-14)
|
||||
|
||||
- **背景与目标**:创建表单「结束类型」组合框左侧选项来自 `ScheduleInvestmentEndType`(`NO_END` / `END_DATE` / `COUNT`),展示服务端 i18n;表单内部仍用 `endMode`(`none` / `date` / `count`)与提交逻辑不变。
|
||||
- **涉及模块**:`scheduleInvestment.ts` 常量、`scheduleInvestmentPlanMapper.ts`(`endMode` ↔ API 枚举)、`RecurringInvestmentView.vue`。
|
||||
- **改动类型**:功能优化(UI)。
|
||||
|
||||
### 2. 方法级改动分析
|
||||
|
||||
| 位置 | 差异 |
|
||||
|------|------|
|
||||
| `endModeToScheduleEndType` / `scheduleEndTypeToEndMode` | UI 与 API 结束类型互转。 |
|
||||
| `endModeOptions` | 系统码驱动下拉,`codeValue` 映射为 `endMode`。 |
|
||||
| `endTypeLabel` | 列表/详情/确认中 GTC 等结束类型文案与下拉一致。 |
|
||||
| `ensureCodeTypes` | 增加 `ScheduleInvestmentEndType`。 |
|
||||
|
||||
### 3. 调用方与影响范围
|
||||
|
||||
- 创建表单结束类型选择与相关展示;**破坏性变更**:否。
|
||||
|
||||
### 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。
|
||||
- **回滚方式是否简单**:是。
|
||||
|
||||
### 5. 验证与测试
|
||||
|
||||
- `npm run build`;结束类型下拉显示服务端文案(如 GTC 对应 `NO_END`)。
|
||||
|
||||
---
|
||||
|
||||
## 补充:我的计划列表分页(2026-10-14)
|
||||
|
||||
- **背景与目标**:「我的定期投资计划」表格/卡片列表对接 `plan/page` 分页,避免一次拉取过多记录。
|
||||
- **涉及模块**:`RecurringInvestmentView.vue`。
|
||||
- **改动类型**:功能优化(UI)。
|
||||
|
||||
### 2. 方法级改动分析
|
||||
|
||||
| 位置 | 差异 |
|
||||
|------|------|
|
||||
| `loadPlans` | 使用 `current` / `size` 请求,写入 `planPageTotal`;支持 `loadPlans(1)` 重置页码。 |
|
||||
| `el-pagination` | 默认每页 10 条,可选 10/20/50;移动端精简 layout。 |
|
||||
| 空状态 | 以 `planPageTotal === 0` 判断无计划。 |
|
||||
|
||||
### 3. 调用方与影响范围
|
||||
|
||||
- 计划列表展示与创建/终止后刷新;**破坏性变更**:否(`runningCount` 仍为当前页内统计,与改前单页数据范围一致)。
|
||||
|
||||
### 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。
|
||||
- **回滚方式是否简单**:是。
|
||||
|
||||
### 5. 验证与测试
|
||||
|
||||
- `npm run build`;翻页、改每页条数后列表与总数一致;新建计划后回到第 1 页。
|
||||
|
||||
@@ -307,3 +307,43 @@
|
||||
- 创建计划与 `plan/next-execution` 请求体 `ctOffsetMinutes` 传 `null`;预览校验文案改为「请先选择交易所与定投周期」。
|
||||
- 确认弹窗去掉 CT 偏移行;计划详情仍展示后端返回的偏移。
|
||||
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||
|
||||
---
|
||||
|
||||
## 补充:开始日期禁选过去日 + 详情执行订单状态列(2026-10-14)
|
||||
|
||||
- 创建表单「开始日期」日期选择器禁用今天之前的日期;提交校验同步拦截。
|
||||
- 计划详情「最近执行」表格与移动端卡片末列展示 `orderStatus`(订单状态)。
|
||||
- 参见 `IMPACT_ANALYSIS.md` 对应补充。
|
||||
|
||||
---
|
||||
|
||||
## 补充:订单状态系统码映射(2026-10-14)
|
||||
|
||||
- 调用 `POST /admin/systemCode/getByCodeTypeBatch` 预加载 `OrderStatus`;`orderStatus` 按 `codeValue` 匹配 `descriptionI18nId`,经 `useServiceI18n` 展示多语言订单状态文案。
|
||||
- 涉及 `api/systemCode.ts`、`stores/systemCode.ts`、`composables/useSystemCode.ts`、`RecurringInvestmentView.vue`。
|
||||
- 验证:`npm run build`;详情「訂單狀態」列显示文案而非裸码值。
|
||||
|
||||
---
|
||||
|
||||
## 补充:定投周期系统码下拉(2026-10-14)
|
||||
|
||||
- `ScheduleInvestmentFrequencyType` 批量加载后驱动创建表单「定投周期」下拉(按 `sort` 排序);列表/详情/确认预览统一 `frequencyTypeLabel`。
|
||||
- 接口不可用时回退本地 `recurring.frequency.*` 文案。
|
||||
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||
|
||||
---
|
||||
|
||||
## 补充:结束类型系统码下拉(2026-10-14)
|
||||
|
||||
- `ScheduleInvestmentEndType` 驱动创建表单结束类型下拉;`NO_END`→`none`、`END_DATE`→`date`、`COUNT`→`count`。
|
||||
- 列表/详情/确认预览的 GTC 等文案经 `endTypeLabel` 与系统码一致。
|
||||
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||
|
||||
---
|
||||
|
||||
## 补充:我的计划列表分页(2026-10-14)
|
||||
|
||||
- `getScheduleInvestmentPlanPage` 分页参数驱动列表;表格与移动端卡片共用 `el-pagination`(默认 10 条/页)。
|
||||
- 新建计划成功后刷新并回到第 1 页。
|
||||
- 详见 `IMPACT_ANALYSIS.md` 本节补充。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
export interface SystemCodeItem {
|
||||
id: string
|
||||
codeType: string
|
||||
codeValue: string
|
||||
descriptionI18nId: string
|
||||
remark?: string
|
||||
sort?: number
|
||||
createBy?: string
|
||||
createOn?: string
|
||||
updateBy?: string
|
||||
updateOn?: string
|
||||
}
|
||||
|
||||
/** 按 codeType 批量查询系统码,返回键为 codeType、值为条目列表 */
|
||||
export type SystemCodeBatchMap = Record<string, SystemCodeItem[]>
|
||||
|
||||
export function getSystemCodeByCodeTypeBatch(
|
||||
codeTypes: string[],
|
||||
): Promise<ApiResponse<SystemCodeBatchMap | SystemCodeItem[]>> {
|
||||
return http.post('/admin/systemCode/getByCodeTypeBatch', {
|
||||
param: codeTypes,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServiceI18nStore } from '@/stores/serviceI18n'
|
||||
import { useSystemCodeStore } from '@/stores/systemCode'
|
||||
|
||||
/** 系统码 codeValue → 当前语言展示文案(经 descriptionI18nId + 服务端 i18n) */
|
||||
export function useSystemCode() {
|
||||
const appStore = useAppStore()
|
||||
const systemCodeStore = useSystemCodeStore()
|
||||
const { syncRevision } = storeToRefs(systemCodeStore)
|
||||
const serviceI18nStore = useServiceI18nStore()
|
||||
|
||||
function codeLabel(codeType: string, codeValue: string | undefined): string {
|
||||
syncRevision.value
|
||||
const raw = codeValue?.trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
const i18nId = systemCodeStore.getDescriptionI18nId(codeType, raw)
|
||||
if (i18nId) {
|
||||
return serviceI18nStore.getText(i18nId, appStore.locale)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function codeSelectOptions(codeType: string): { value: string; label: string }[] {
|
||||
syncRevision.value
|
||||
return systemCodeStore.getSortedCodeValues(codeType).map((value) => ({
|
||||
value,
|
||||
label: codeLabel(codeType, value),
|
||||
}))
|
||||
}
|
||||
|
||||
function hasCodeValue(codeType: string, codeValue: string | undefined): boolean {
|
||||
syncRevision.value
|
||||
const raw = codeValue?.trim()
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
return Boolean(systemCodeStore.getDescriptionI18nId(codeType, raw))
|
||||
}
|
||||
|
||||
function codeLabelWithFallback(
|
||||
codeType: string,
|
||||
codeValue: string | undefined,
|
||||
fallback: (raw: string) => string,
|
||||
): string {
|
||||
const raw = codeValue?.trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
if (hasCodeValue(codeType, codeValue)) {
|
||||
return codeLabel(codeType, codeValue)
|
||||
}
|
||||
return fallback(raw)
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCodeTypes: systemCodeStore.ensureCodeTypes,
|
||||
codeLabel,
|
||||
codeSelectOptions,
|
||||
codeLabelWithFallback,
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export default {
|
||||
executionEmpty: 'No execution records',
|
||||
colScheduledDate: 'Scheduled date',
|
||||
colExecutionStatus: 'Execution',
|
||||
colOrderStatus: 'Order status',
|
||||
colAmount: 'Amount / qty',
|
||||
colFilledQty: 'Filled qty',
|
||||
colFilledAmount: 'Filled amount',
|
||||
|
||||
@@ -51,6 +51,7 @@ export default {
|
||||
executionEmpty: '暂无执行记录',
|
||||
colScheduledDate: '计划执行日',
|
||||
colExecutionStatus: '执行状态',
|
||||
colOrderStatus: '订单状态',
|
||||
colAmount: '金额/数量',
|
||||
colFilledQty: '成交数量',
|
||||
colFilledAmount: '成交金额',
|
||||
|
||||
@@ -51,6 +51,7 @@ export default {
|
||||
executionEmpty: '暫無執行記錄',
|
||||
colScheduledDate: '計劃執行日',
|
||||
colExecutionStatus: '執行狀態',
|
||||
colOrderStatus: '訂單狀態',
|
||||
colAmount: '金額/數量',
|
||||
colFilledQty: '成交數量',
|
||||
colFilledAmount: '成交金額',
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getSystemCodeByCodeTypeBatch,
|
||||
type SystemCodeBatchMap,
|
||||
type SystemCodeItem,
|
||||
} from '@/api/systemCode'
|
||||
|
||||
function sortCodeItems(items: SystemCodeItem[]): SystemCodeItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const sortA = a.sort ?? 0
|
||||
const sortB = b.sort ?? 0
|
||||
if (sortA !== sortB) {
|
||||
return sortA - sortB
|
||||
}
|
||||
return a.codeValue.localeCompare(b.codeValue)
|
||||
})
|
||||
}
|
||||
|
||||
function collectItemsFromBatch(
|
||||
data: SystemCodeBatchMap | SystemCodeItem[] | undefined,
|
||||
requestedTypes: string[],
|
||||
): SystemCodeItem[] {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
const list: SystemCodeItem[] = []
|
||||
const push = (item: SystemCodeItem, fallbackType?: string) => {
|
||||
const type = (item.codeType || fallbackType || '').trim()
|
||||
const value = item.codeValue?.trim()
|
||||
const i18nId = item.descriptionI18nId?.trim()
|
||||
if (!type || !value || !i18nId) {
|
||||
return
|
||||
}
|
||||
list.push({ ...item, codeType: type, codeValue: value, descriptionI18nId: i18nId })
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((item) => push(item))
|
||||
return list
|
||||
}
|
||||
for (const type of requestedTypes) {
|
||||
const rows = data[type]
|
||||
if (Array.isArray(rows)) {
|
||||
rows.forEach((item) => push(item, type))
|
||||
}
|
||||
}
|
||||
for (const [type, rows] of Object.entries(data)) {
|
||||
if (Array.isArray(rows)) {
|
||||
rows.forEach((item) => push(item, type))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function mergeIntoItemsByType(
|
||||
target: Record<string, SystemCodeItem[]>,
|
||||
incoming: SystemCodeItem[],
|
||||
) {
|
||||
for (const item of incoming) {
|
||||
const type = item.codeType
|
||||
if (!target[type]) {
|
||||
target[type] = []
|
||||
}
|
||||
const index = target[type].findIndex((row) => row.codeValue === item.codeValue)
|
||||
if (index >= 0) {
|
||||
target[type][index] = item
|
||||
} else {
|
||||
target[type].push(item)
|
||||
}
|
||||
}
|
||||
for (const type of Object.keys(target)) {
|
||||
target[type] = sortCodeItems(target[type])
|
||||
}
|
||||
}
|
||||
|
||||
function cloneItemsByType(source: Record<string, SystemCodeItem[]>) {
|
||||
const next: Record<string, SystemCodeItem[]> = {}
|
||||
for (const [type, items] of Object.entries(source)) {
|
||||
next[type] = [...items]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export const useSystemCodeStore = defineStore('systemCode', () => {
|
||||
const itemsByType = ref<Record<string, SystemCodeItem[]>>({})
|
||||
/** 系统码加载完成后递增,供视图依赖刷新 */
|
||||
const syncRevision = ref(0)
|
||||
const loadedTypes = new Set<string>()
|
||||
let batchLoading: Promise<void> | null = null
|
||||
const pendingTypes: string[] = []
|
||||
|
||||
function getDescriptionI18nId(codeType: string, codeValue: string): string | undefined {
|
||||
const item = itemsByType.value[codeType]?.find((row) => row.codeValue === codeValue.trim())
|
||||
return item?.descriptionI18nId
|
||||
}
|
||||
|
||||
function getSortedCodeValues(codeType: string): string[] {
|
||||
return (itemsByType.value[codeType] ?? []).map((item) => item.codeValue)
|
||||
}
|
||||
|
||||
async function ensureCodeTypes(codeTypes: string[]) {
|
||||
const normalized = codeTypes.map((t) => t.trim()).filter(Boolean)
|
||||
const missing = normalized.filter((t) => !loadedTypes.has(t))
|
||||
if (!missing.length) {
|
||||
if (batchLoading) {
|
||||
await batchLoading
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingTypes.push(...missing)
|
||||
if (batchLoading) {
|
||||
await batchLoading
|
||||
return
|
||||
}
|
||||
batchLoading = (async () => {
|
||||
while (pendingTypes.length) {
|
||||
const batch = [...new Set(pendingTypes.splice(0, pendingTypes.length))]
|
||||
try {
|
||||
const result = await getSystemCodeByCodeTypeBatch(batch)
|
||||
const merged = { ...itemsByType.value }
|
||||
mergeIntoItemsByType(merged, collectItemsFromBatch(result.data, batch))
|
||||
itemsByType.value = cloneItemsByType(merged)
|
||||
syncRevision.value += 1
|
||||
batch.forEach((t) => loadedTypes.add(t))
|
||||
} catch (e) {
|
||||
console.error('Failed to load system codes:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
})()
|
||||
try {
|
||||
await batchLoading
|
||||
} finally {
|
||||
batchLoading = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
itemsByType,
|
||||
syncRevision,
|
||||
getDescriptionI18nId,
|
||||
getSortedCodeValues,
|
||||
ensureCodeTypes,
|
||||
}
|
||||
})
|
||||
Vendored
+1
@@ -20,6 +20,7 @@ declare module 'vue' {
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface RecurringPlanDraft {
|
||||
orderPriceType?: 'MARKET' | 'LIMIT'
|
||||
limitPrice?: number
|
||||
frequencyDay?: string
|
||||
frequencyType?: 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||
ctOffsetMinutes?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ export type WeekdayCode = 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | 'SUN'
|
||||
|
||||
export const FREQUENCY_TYPE_KEYS: FrequencyType[] = ['DAILY', 'WEEKLY', 'MONTHLY']
|
||||
|
||||
/** 定投周期系统码类型(`getByCodeTypeBatch`) */
|
||||
export const SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE = 'ScheduleInvestmentFrequencyType'
|
||||
|
||||
/** 结束条件系统码类型(`getByCodeTypeBatch`) */
|
||||
export const SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE = 'ScheduleInvestmentEndType'
|
||||
|
||||
export const WEEKDAY_CODES: WeekdayCode[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
|
||||
|
||||
export const INVESTMENT_TYPE_KEYS: InvestmentType[] = ['AMOUNT', 'QUANTITY']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment'
|
||||
import type { EndMode, InvestAsset, PlanStatus, RecurringPlan } from '@/types/recurringInvestment'
|
||||
import type {
|
||||
FrequencyType,
|
||||
ScheduleEndType,
|
||||
@@ -49,6 +49,30 @@ export function normalizeScheduleEndType(raw: string): ScheduleEndType | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function endModeToScheduleEndType(mode: EndMode): ScheduleEndType {
|
||||
if (mode === 'count') {
|
||||
return 'COUNT'
|
||||
}
|
||||
if (mode === 'date') {
|
||||
return 'END_DATE'
|
||||
}
|
||||
return 'NO_END'
|
||||
}
|
||||
|
||||
export function scheduleEndTypeToEndMode(raw: string): EndMode | null {
|
||||
const endType = normalizeScheduleEndType(raw)
|
||||
if (endType === 'COUNT') {
|
||||
return 'count'
|
||||
}
|
||||
if (endType === 'END_DATE') {
|
||||
return 'date'
|
||||
}
|
||||
if (endType === 'NO_END') {
|
||||
return 'none'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveEndFields(record: ScheduleInvestmentPlanRecord): Pick<
|
||||
RecurringPlan,
|
||||
'endMode' | 'endDate' | 'endCount' | 'noEnd' | 'endDisplay'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/api/product'
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||
import { useServiceI18n } from '@/composables/useServiceI18n'
|
||||
import { useSystemCode } from '@/composables/useSystemCode'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRecurringPlansStore } from '@/stores/recurringPlans'
|
||||
import type {
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
import {
|
||||
FREQUENCY_TYPE_KEYS,
|
||||
INVESTMENT_TYPE_KEYS,
|
||||
SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE,
|
||||
SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE,
|
||||
type FrequencyType,
|
||||
type InvestmentType,
|
||||
type OrderPriceType,
|
||||
@@ -47,9 +50,11 @@ import {
|
||||
scheduleProductLineDisplay,
|
||||
} from '@/utils/investAssetDisplay'
|
||||
import {
|
||||
endModeToScheduleEndType,
|
||||
mapApiPlanStatus,
|
||||
mapSchedulePlanRecordToRecurringPlan,
|
||||
normalizeScheduleEndType,
|
||||
scheduleEndTypeToEndMode,
|
||||
} from '@/utils/scheduleInvestmentPlanMapper'
|
||||
import {
|
||||
formatFrequencyDayLabel,
|
||||
@@ -79,6 +84,7 @@ interface FormState {
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { si18n } = useServiceI18n()
|
||||
const { codeLabel, codeLabelWithFallback, codeSelectOptions, ensureCodeTypes } = useSystemCode()
|
||||
const { isMobile, isTablet, isDesktop } = useBreakpoint()
|
||||
|
||||
const detailDialogWidth = computed(() => {
|
||||
@@ -119,7 +125,14 @@ const nextExecutionPreviewVisible = ref(false)
|
||||
const nextExecutionPreviewLoading = ref(false)
|
||||
const nextExecutionPreview = ref<ScheduleInvestmentNextExecutionData | null>(null)
|
||||
|
||||
const PLAN_PAGE_SIZE = 50
|
||||
const PLAN_PAGE_SIZE_OPTIONS = [10, 20, 50] as const
|
||||
const planPageCurrent = ref(1)
|
||||
const planPageSize = ref(10)
|
||||
const planPageTotal = ref(0)
|
||||
|
||||
const planPaginationLayout = computed(() =>
|
||||
isMobile.value ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next',
|
||||
)
|
||||
|
||||
const form = reactive<FormState>({
|
||||
exchangeCode: '',
|
||||
@@ -156,12 +169,45 @@ const exchangeSelectOptions = computed(() =>
|
||||
|
||||
const gutter = computed(() => (isMobile.value ? 12 : 20))
|
||||
|
||||
const frequencyOptions = computed(() =>
|
||||
FREQUENCY_TYPE_KEYS.map((key) => ({
|
||||
const frequencyOptions = computed(() => {
|
||||
const fromSystemCode = codeSelectOptions(SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE)
|
||||
if (fromSystemCode.length) {
|
||||
return fromSystemCode
|
||||
}
|
||||
return FREQUENCY_TYPE_KEYS.map((key) => ({
|
||||
value: key,
|
||||
label: t(`recurring.frequency.${key}`),
|
||||
})),
|
||||
)
|
||||
}))
|
||||
})
|
||||
|
||||
function frequencyTypeLabel(code: FrequencyType | string | undefined) {
|
||||
return codeLabelWithFallback(SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE, code, (raw) => {
|
||||
const key = raw as FrequencyType
|
||||
if (FREQUENCY_TYPE_KEYS.includes(key)) {
|
||||
return t(`recurring.frequency.${key}`)
|
||||
}
|
||||
return raw
|
||||
})
|
||||
}
|
||||
|
||||
function endTypeLabel(input: EndMode | string | undefined) {
|
||||
const codeValue =
|
||||
input === 'none' || input === 'date' || input === 'count'
|
||||
? endModeToScheduleEndType(input)
|
||||
: normalizeScheduleEndType(input ?? '') ?? undefined
|
||||
return codeLabelWithFallback(SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE, codeValue, (raw) => {
|
||||
if (raw === 'NO_END') {
|
||||
return t('recurring.endByGtc')
|
||||
}
|
||||
if (raw === 'END_DATE') {
|
||||
return t('recurring.endByDate')
|
||||
}
|
||||
if (raw === 'COUNT') {
|
||||
return t('recurring.endByCount')
|
||||
}
|
||||
return raw
|
||||
})
|
||||
}
|
||||
|
||||
const investmentTypeOptions = computed(() =>
|
||||
INVESTMENT_TYPE_KEYS.map((key) => ({
|
||||
@@ -178,11 +224,23 @@ const investmentValueHint = computed(() =>
|
||||
form.investmentType === 'AMOUNT' ? t('recurring.amountHint') : t('recurring.quantityHint'),
|
||||
)
|
||||
|
||||
const endModeOptions = computed(() => [
|
||||
{ value: 'date' as EndMode, label: t('recurring.endByDate') },
|
||||
{ value: 'none' as EndMode, label: t('recurring.endByGtc') },
|
||||
{ value: 'count' as EndMode, label: t('recurring.endByCount') },
|
||||
])
|
||||
const endModeOptions = computed(() => {
|
||||
const fromSystemCode = codeSelectOptions(SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE)
|
||||
if (fromSystemCode.length) {
|
||||
return fromSystemCode.flatMap((option) => {
|
||||
const mode = scheduleEndTypeToEndMode(option.value)
|
||||
if (!mode) {
|
||||
return []
|
||||
}
|
||||
return [{ value: mode, label: option.label }]
|
||||
})
|
||||
}
|
||||
return [
|
||||
{ value: 'date' as EndMode, label: t('recurring.endByDate') },
|
||||
{ value: 'none' as EndMode, label: t('recurring.endByGtc') },
|
||||
{ value: 'count' as EndMode, label: t('recurring.endByCount') },
|
||||
]
|
||||
})
|
||||
|
||||
const endValueFieldError = computed(() => {
|
||||
if (form.endMode === 'count') {
|
||||
@@ -411,24 +469,50 @@ async function loadExchangeOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
async function loadPlans(resetPage?: number) {
|
||||
if (resetPage !== undefined) {
|
||||
planPageCurrent.value = resetPage
|
||||
}
|
||||
plansLoading.value = true
|
||||
try {
|
||||
const res = await getScheduleInvestmentPlanPage({ current: 1, size: PLAN_PAGE_SIZE })
|
||||
const records = res.data?.records
|
||||
const res = await getScheduleInvestmentPlanPage({
|
||||
current: planPageCurrent.value,
|
||||
size: planPageSize.value,
|
||||
})
|
||||
const data = res.data
|
||||
const records = data?.records
|
||||
planPageTotal.value = Number(data?.total ?? 0)
|
||||
if (data?.current != null) {
|
||||
planPageCurrent.value = data.current
|
||||
}
|
||||
plansStore.setPlans(
|
||||
Array.isArray(records) ? records.map(mapSchedulePlanRecordToRecurringPlan) : [],
|
||||
)
|
||||
} catch {
|
||||
plansStore.setPlans([])
|
||||
planPageTotal.value = 0
|
||||
} finally {
|
||||
plansLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPlanPageChange() {
|
||||
loadPlans()
|
||||
}
|
||||
|
||||
function onPlanPageSizeChange(size: number) {
|
||||
planPageSize.value = size
|
||||
loadPlans(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadExchangeOptions()
|
||||
loadPlans()
|
||||
ensureCodeTypes([
|
||||
'OrderStatus',
|
||||
SCHEDULE_INVESTMENT_FREQUENCY_CODE_TYPE,
|
||||
SCHEDULE_INVESTMENT_END_TYPE_CODE_TYPE,
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
|
||||
function planInvestmentLabel(plan: RecurringPlan) {
|
||||
@@ -445,7 +529,7 @@ function planInvestmentDisplay(plan: RecurringPlan) {
|
||||
|
||||
function intervalTextForPlan(plan: RecurringPlan) {
|
||||
if (plan.frequencyType) {
|
||||
let text = t(`recurring.frequency.${plan.frequencyType}`)
|
||||
let text = frequencyTypeLabel(plan.frequencyType)
|
||||
if (plan.frequencyDay) {
|
||||
const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t)
|
||||
text = `${text} · ${dayLabel}`
|
||||
@@ -460,7 +544,7 @@ function endTextForPlan(plan: RecurringPlan) {
|
||||
return t('recurring.endCountValue', { n: plan.endCount })
|
||||
}
|
||||
if (plan.endMode === 'none' || plan.noEnd || !plan.endDate) {
|
||||
return t('recurring.endByGtc')
|
||||
return endTypeLabel('none')
|
||||
}
|
||||
return plan.endDate
|
||||
}
|
||||
@@ -470,11 +554,27 @@ function buildEndDisplay() {
|
||||
return t('recurring.endCountValue', { n: Number.parseInt(form.endCount, 10) })
|
||||
}
|
||||
if (form.endMode === 'none') {
|
||||
return t('recurring.endByGtc')
|
||||
return endTypeLabel('none')
|
||||
}
|
||||
return form.endDate
|
||||
}
|
||||
|
||||
/** 创建表单开始日期:不可选今天之前(按本地日历日) */
|
||||
function disableFormStartDate(date: Date): boolean {
|
||||
const startOfToday = new Date()
|
||||
startOfToday.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < startOfToday.getTime()
|
||||
}
|
||||
|
||||
function isStartDateBeforeToday(isoDate: string): boolean {
|
||||
const [y, m, d] = isoDate.split('-').map((part) => Number.parseInt(part, 10))
|
||||
if (!y || !m || !d) return false
|
||||
const picked = new Date(y, m - 1, d)
|
||||
const startOfToday = new Date()
|
||||
startOfToday.setHours(0, 0, 0, 0)
|
||||
return picked.getTime() < startOfToday.getTime()
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
assetInvalid.value = false
|
||||
fieldError.exchangeCode = false
|
||||
@@ -499,7 +599,7 @@ function validate(): boolean {
|
||||
assetInvalid.value = true
|
||||
ok = false
|
||||
}
|
||||
if (!form.startDate) {
|
||||
if (!form.startDate || isStartDateBeforeToday(form.startDate)) {
|
||||
fieldError.startDate = true
|
||||
ok = false
|
||||
}
|
||||
@@ -654,6 +754,7 @@ function onSubmit() {
|
||||
investmentType: form.investmentType,
|
||||
quantity,
|
||||
orderPriceType: 'MARKET',
|
||||
frequencyType: form.frequencyType as FrequencyType,
|
||||
frequencyDay: showFrequencyDay.value ? form.frequencyDay : undefined,
|
||||
}
|
||||
confirmVisible.value = true
|
||||
@@ -703,7 +804,7 @@ async function confirmCreate() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await createScheduleInvestmentPlan(body)
|
||||
await loadPlans()
|
||||
await loadPlans(1)
|
||||
ElMessage.success(t('recurring.createdToast'))
|
||||
confirmVisible.value = false
|
||||
draft.value = null
|
||||
@@ -783,7 +884,7 @@ function formatDetailOrderPrice(plan: ScheduleInvestmentPlanDetailPlan) {
|
||||
}
|
||||
|
||||
function formatDetailFrequency(plan: ScheduleInvestmentPlanDetailPlan) {
|
||||
let text = t(`recurring.frequency.${plan.frequencyType}`)
|
||||
let text = frequencyTypeLabel(plan.frequencyType)
|
||||
if (plan.frequencyDay) {
|
||||
const dayLabel = formatFrequencyDayLabel(plan.frequencyType, plan.frequencyDay, t)
|
||||
text = `${text} · ${dayLabel}`
|
||||
@@ -800,7 +901,7 @@ function formatDetailEndCondition(plan: ScheduleInvestmentPlanDetailPlan) {
|
||||
}
|
||||
}
|
||||
if (endType === 'NO_END' || endType === null || !plan.endDate) {
|
||||
return t('recurring.endByGtc')
|
||||
return endTypeLabel('NO_END')
|
||||
}
|
||||
return plan.endDate
|
||||
}
|
||||
@@ -867,6 +968,14 @@ function formatExecutionAvgFilledPrice(row: ScheduleInvestmentPlanExecutionRecor
|
||||
return formatHkd(parsePlanDecimal(row.avgFilledPrice))
|
||||
}
|
||||
|
||||
function formatOrderStatus(code: string | undefined) {
|
||||
if (!code?.trim()) {
|
||||
return '—'
|
||||
}
|
||||
const label = codeLabel('OrderStatus', code)
|
||||
return label || '—'
|
||||
}
|
||||
|
||||
function closePlanDetail() {
|
||||
detailVisible.value = false
|
||||
}
|
||||
@@ -887,6 +996,7 @@ async function openPlanDetail(plan: RecurringPlan) {
|
||||
detailPlan.value = null
|
||||
detailExecutions.value = []
|
||||
try {
|
||||
await ensureCodeTypes(['OrderStatus']).catch(() => undefined)
|
||||
const res = await getScheduleInvestmentPlanDetail({ planNo })
|
||||
detailPlan.value = res.data?.plan ?? null
|
||||
detailExecutions.value = Array.isArray(res.data?.recentExecutions)
|
||||
@@ -950,12 +1060,14 @@ const previewInterval = computed(() => {
|
||||
if (!draft.value) {
|
||||
return ''
|
||||
}
|
||||
const freqKey = draft.value.interval === 'daily'
|
||||
? 'DAILY'
|
||||
: draft.value.interval === 'weekly'
|
||||
? 'WEEKLY'
|
||||
: 'MONTHLY'
|
||||
let text = t(`recurring.frequency.${freqKey}`)
|
||||
const freqKey =
|
||||
draft.value.frequencyType ??
|
||||
(draft.value.interval === 'daily'
|
||||
? 'DAILY'
|
||||
: draft.value.interval === 'weekly'
|
||||
? 'WEEKLY'
|
||||
: 'MONTHLY')
|
||||
let text = frequencyTypeLabel(freqKey)
|
||||
if (draft.value.frequencyDay) {
|
||||
const dayLabel = formatFrequencyDayLabel(freqKey, draft.value.frequencyDay, t)
|
||||
text = `${text} · ${dayLabel}`
|
||||
@@ -989,7 +1101,7 @@ const previewOrderPrice = computed(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!plansLoading && !plansStore.plans.length"
|
||||
v-if="!plansLoading && planPageTotal === 0"
|
||||
class="plan-empty"
|
||||
>
|
||||
{{ t('recurring.planEmpty') }}
|
||||
@@ -1104,6 +1216,20 @@ const previewOrderPrice = computed(() => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="planPageTotal > 0" class="plan-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="planPageCurrent"
|
||||
v-model:page-size="planPageSize"
|
||||
:page-sizes="[...PLAN_PAGE_SIZE_OPTIONS]"
|
||||
:total="planPageTotal"
|
||||
:layout="planPaginationLayout"
|
||||
:disabled="plansLoading"
|
||||
background
|
||||
@current-change="onPlanPageChange"
|
||||
@size-change="onPlanPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="panel" novalidate @submit.prevent="onSubmit" @reset.prevent="resetForm">
|
||||
@@ -1279,6 +1405,7 @@ const previewOrderPrice = computed(() => {
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="t('recurring.startDate')"
|
||||
class="control-date"
|
||||
:disabled-date="disableFormStartDate"
|
||||
@change="fieldError.startDate = false"
|
||||
/>
|
||||
</div>
|
||||
@@ -1455,6 +1582,10 @@ const previewOrderPrice = computed(() => {
|
||||
<dt>{{ t('recurring.planDetail.colAvgFilledPrice') }}</dt>
|
||||
<dd class="num">{{ formatExecutionAvgFilledPrice(row) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('recurring.planDetail.colOrderStatus') }}</dt>
|
||||
<dd>{{ formatOrderStatus(row.orderStatus) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1471,6 +1602,7 @@ const previewOrderPrice = computed(() => {
|
||||
<th>{{ t('recurring.planDetail.colFilledAmount') }}</th>
|
||||
<th>{{ t('recurring.planDetail.colAvgFilledPrice') }}</th>
|
||||
<th class="l">{{ t('recurring.planDetail.colExecutionStatus') }}</th>
|
||||
<th class="l">{{ t('recurring.planDetail.colOrderStatus') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1486,6 +1618,7 @@ const previewOrderPrice = computed(() => {
|
||||
<td class="num">{{ formatExecutionFilledAmount(row) }}</td>
|
||||
<td class="num">{{ formatExecutionAvgFilledPrice(row) }}</td>
|
||||
<td class="l">{{ row.executionStatus || '—' }}</td>
|
||||
<td class="l">{{ formatOrderStatus(row.orderStatus) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1631,6 +1764,24 @@ const previewOrderPrice = computed(() => {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.plan-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 18px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
@include respond-down(md) {
|
||||
justify-content: center;
|
||||
padding: 12px 12px 16px;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
row-gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plan-table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
|
||||
Reference in New Issue
Block a user