Enhance recurring investment plan details and API integration:

- Introduce `productCode` for API submissions, differentiating it from `productCodeUnique`.
- Remove `actualTradeDate` and `orderStatus` from the execution records table, adding `filledQty`, `filledAmount`, and `avgFilledPrice` instead.
- Implement responsive design adjustments for the execution records display across devices.
- Update internationalization files to reflect new column names and hints for table navigation.
- Document changes in `IMPACT_ANALYSIS.md` and `TASK_SUMMARY.md` for clarity on the updates.
This commit is contained in:
justin
2026-10-13 11:25:10 +08:00
parent cc90cc69e2
commit b73db1b193
10 changed files with 417 additions and 44 deletions
+121
View File
@@ -896,3 +896,124 @@
### 5. 验证与测试
- 填妥交易所与周期后点「预览下次执行」:按钮 loading、弹窗内 loading,成功后显示日期与时间;接口失败时弹窗关闭且已有错误提示。
---
## 补充:创建计划 productCode 入参(2026-10-12)
### 1. 改动概览
- **背景与目标**:创建定投计划(及 CT 时间选项)应提交接口字段 `productCode`(如 `1`),不得使用展示用 `productCodeUnique`(如 `1.HK`)。
- **涉及模块**:`InvestAsset`、`mapSimpleStockToAsset`、`buildScheduleInvestmentCreateBody`、`resolvePickedProductCode`、`scheduleInvestmentPlanMapper`。
- **改动类型**:缺陷修复。
### 2. 方法级改动分析
| 位置 | 差异 |
|------|------|
| `InvestAsset` | 新增 `productCode`(接口用),与 `productCodeUnique`(展示用)区分。 |
| `mapSimpleStockToAsset` | 从 `getSimpleStockPage` 写入 `productCode`;`code` 仍为 `productCodeUnique` 优先。 |
| `buildScheduleInvestmentCreateBody` | `productCode` 仅取自 `asset.productCode`。 |
| `resolvePickedProductCode` | CT 选项请求与创建计划一致,仅用 `asset.productCode`。 |
### 3. 调用方与影响范围
- 创建计划、`options/ct-time`;**破坏性变更**:否(修正错误入参)。
### 4. 风险与回滚
- **风险级别**:低;**回滚方式是否简单**:是。
### 5. 验证与测试
- 选择 SEHK `1.HK` 资产创建计划,请求体 `productCode` 为 `1`;CT 偏移选项请求同上。
---
## 补充:计划详情执行记录移除委托状态列(2026-10-12)
### 1. 改动概览
- **背景与目标**:计划详情「最近执行」表格不再展示委托状态。
- **涉及模块**:`RecurringInvestmentView.vue`、三语言 `planDetail.colOrderStatus`。
- **改动类型**:界面调整。
### 2. 方法级改动分析
| 位置 | 差异 |
|------|------|
| `detail-exec-table` | 删除 `colOrderStatus` 表头与 `row.orderStatus` 单元格。 |
### 3. 调用方与影响范围
- 仅详情弹窗执行记录表;**破坏性变更**:否(接口仍可能返回 `orderStatus`,前端不展示)。
### 4. 风险与回滚
- **风险级别**:低;**回滚方式是否简单**:是。
### 5. 验证与测试
- 打开计划详情,确认执行记录表为四列:计划执行日、实际交易日、金额/数量、执行状态。
---
## 补充:计划详情执行记录成交字段列(2026-10-13)
### 1. 改动概览
- **背景与目标**:详情「最近执行」表去掉「实际交易日」,展示关联委托成交的 `filledQty`、`filledAmount`、`avgFilledPrice`。
- **涉及模块**:`ScheduleInvestmentPlanExecutionRecord`、`RecurringInvestmentView.vue`、三语言 `planDetail` 列文案。
- **改动类型**:界面调整 + 类型扩展。
### 2. 方法级改动分析
| 位置 | 差异 |
|------|------|
| `ScheduleInvestmentPlanExecutionRecord` | 新增可选字段 `filledQty`、`filledAmount`、`avgFilledPrice`。 |
| `detail-exec-table` | 移除 `actualTradeDate` 列;新增三列及 `formatExecutionFilledQty/Amount/AvgFilledPrice` 格式化。 |
| i18n | 删除 `colActualDate`;新增 `colFilledQty`、`colFilledAmount`、`colAvgFilledPrice`。 |
### 3. 调用方与影响范围
- 仅计划详情弹窗执行记录表;数据来自 `recentExecutions`,无额外请求。**破坏性变更**:否(列展示调整,接口字段向后兼容)。
### 4. 风险与回滚
- **风险级别**:低;**回滚方式是否简单**:是。
### 5. 验证与测试
- 打开计划详情,确认列为:计划执行日、金额/数量、成交数量、成交金额、成交均价、执行状态;无成交数据时三列显示 `0` 或 `—`(字段缺失为 `—`,数值 `0` 正常格式化)。
---
## 补充:计划详情「最近执行」响应式布局(2026-10-13)
### 1. 改动概览
- **背景与目标**:执行记录列增多后,加宽详情弹窗、移动端卡片化、平板横滑提示与表头首列 sticky。
- **涉及模块**:`RecurringInvestmentView.vue`、三语言 `executionTableScrollHint`。
- **改动类型**:界面 / 响应式。
### 2. 方法级改动分析
| 位置 | 差异 |
|------|------|
| `detailDialogWidth` | 移动 `520px`、平板 `720px`、桌面 `920px`(均 `96vw` 上限)。 |
| `detail-exec-block` | `<768px` 每条执行为卡片;否则表格 + `.app-table-scroll`。 |
| `.detail-exec-table` | 紧凑 padding、表头可换行、计划执行日列 sticky。 |
### 3. 调用方与影响范围
- 仅计划详情弹窗;**破坏性变更**:否。
### 4. 风险与回滚
- **风险级别**:低;**回滚方式是否简单**:是。
### 5. 验证与测试
- 移动宽:执行记录为卡片,无横向挤压。
- 平板 / 窄桌面:表格可横滑,见提示文案;首列固定。
- 宽桌面:弹窗加宽后六列表格可读。
+28
View File
@@ -270,3 +270,31 @@
- `next-execution` 响应改为 `nextExecutionDate`、`nextExecutionTime`;弹窗仅展示「下次执行日」「执行时间」。
- 请求期间仅按钮 `:loading`;数据返回后再打开弹窗。
- 参见 `IMPACT_ANALYSIS.md` 对应补充。
---
## 补充:创建计划使用 productCode(2026-10-12)
- `InvestAsset` 区分 `productCode`(接口)与 `productCodeUnique`(展示);创建与 CT 选项请求提交 `productCode`(如 `1`)。
- 参见 `IMPACT_ANALYSIS.md` 对应补充。
---
## 补充:计划详情执行记录去掉委托状态列(2026-10-12)
- 详情弹窗「最近执行」表格移除「委托状态」列及 `colOrderStatus` 文案。
- 参见 `IMPACT_ANALYSIS.md` 对应补充。
---
## 补充:计划详情执行记录成交三列(2026-10-13)
- 移除「实际交易日」列;新增成交数量、成交金额、成交均价(`filledQty` / `filledAmount` / `avgFilledPrice`)。
- 参见 `IMPACT_ANALYSIS.md` 对应补充。
---
## 补充:最近执行表响应式(2026-10-13)
- 详情弹窗按断点加宽;移动端执行记录卡片列表,平板/桌面表格横滑 + 首列 sticky + 滑动提示。
- 参见 `IMPACT_ANALYSIS.md` 对应补充。
+4 -2
View File
@@ -50,10 +50,12 @@ export default {
recentExecutions: 'Recent executions',
executionEmpty: 'No execution records',
colScheduledDate: 'Scheduled date',
colActualDate: 'Trade date',
colExecutionStatus: 'Execution',
colOrderStatus: 'Order',
colAmount: 'Amount / qty',
colFilledQty: 'Filled qty',
colFilledAmount: 'Filled amount',
colAvgFilledPrice: 'Avg fill price',
executionTableScrollHint: 'Swipe horizontally to see all columns',
missingPlanNo: 'Plan number is missing',
},
createTitle: 'Create recurring investment',
+4 -2
View File
@@ -50,10 +50,12 @@ export default {
recentExecutions: '最近执行',
executionEmpty: '暂无执行记录',
colScheduledDate: '计划执行日',
colActualDate: '实际交易日',
colExecutionStatus: '执行状态',
colOrderStatus: '委托状态',
colAmount: '金额/数量',
colFilledQty: '成交数量',
colFilledAmount: '成交金额',
colAvgFilledPrice: '成交均价',
executionTableScrollHint: '表格可左右滑动查看全部字段',
missingPlanNo: '缺少计划编号,无法查询详情',
},
createTitle: '创建定期投资',
+4 -2
View File
@@ -50,10 +50,12 @@ export default {
recentExecutions: '最近執行',
executionEmpty: '暫無執行記錄',
colScheduledDate: '計劃執行日',
colActualDate: '實際交易日',
colExecutionStatus: '執行狀態',
colOrderStatus: '委託狀態',
colAmount: '金額/數量',
colFilledQty: '成交數量',
colFilledAmount: '成交金額',
colAvgFilledPrice: '成交均價',
executionTableScrollHint: '表格可左右滑動查看全部欄位',
missingPlanNo: '缺少計劃編號,無法查詢詳情',
},
createTitle: '創建定期投資',
+3
View File
@@ -24,6 +24,9 @@ export interface InvestAsset {
/** 对接产品接口时的主键,如 SZMK-2728 */
productKey?: string
exchangeCode?: string
/** 下单/接口用产品代号,如 1 */
productCode?: string
/** 界面展示用交易所唯一码,如 1.HK */
productCodeUnique?: string
}
+6
View File
@@ -189,6 +189,12 @@ export interface ScheduleInvestmentPlanExecutionRecord {
limitPrice?: string | number | null
orderStatus?: string
executionStatus?: string
/** Filled quantity from linked order */
filledQty?: string | number | null
/** Filled amount from linked order */
filledAmount?: string | number | null
/** Average filled price = filledAmount / filledQty when filledQty > 0 */
avgFilledPrice?: string | number | null
failureCode?: string
failureMessage?: string
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function buildScheduleInvestmentCreateBody(
input: ScheduleFormPayloadInput,
): ScheduleInvestmentCreateBody {
const endType = resolveEndType(input)
const productCode = input.asset.productCodeUnique || input.asset.code
const productCode = input.asset.productCode?.trim() ?? ''
const body: ScheduleInvestmentCreateBody = {
accountId: input.accountId ?? '',
@@ -81,6 +81,7 @@ export function mapSchedulePlanRecordToRecurringPlan(
engName: record.productEngName?.trim() ?? '',
localName: record.productLocalName?.trim() ?? '',
exchangeCode: record.exchangeCode,
productCode: code,
productCodeUnique: record.productCode,
}
const amount =
+228 -20
View File
@@ -83,7 +83,14 @@ interface FormState {
const { t, locale } = useI18n()
const { si18n } = useServiceI18n()
const { isMobile } = useBreakpoint()
const { isMobile, isTablet, isDesktop } = useBreakpoint()
const detailDialogWidth = computed(() => {
if (isMobile.value) return 'min(520px, 96vw)'
if (isDesktop.value) return 'min(920px, 96vw)'
if (isTablet.value) return 'min(720px, 96vw)'
return 'min(720px, 96vw)'
})
const plansStore = useRecurringPlansStore()
const authStore = useAuthStore()
@@ -314,7 +321,7 @@ function resolvePickedProductCode(): string {
if (!asset) {
return ''
}
return asset.productCodeUnique?.trim() || asset.code.trim()
return asset.productCode?.trim() ?? ''
}
watch(
@@ -345,16 +352,18 @@ function mapSimpleStockToAsset(item: SimpleStockItem): InvestAsset {
const type: AssetType = item.productType === '1' ? 'stock' : 'etf'
const engName = item.engName?.trim() ?? ''
const localName = item.localName?.trim() ?? ''
const productCode = item.productCode?.trim() || ''
const productCodeUnique = item.productCodeUnique?.trim() || ''
return {
code: productCodeUnique || item.productCode,
name: localName || engName || productCodeUnique || item.productCode,
code: productCodeUnique || productCode,
name: localName || engName || productCodeUnique || productCode,
engName,
localName,
type,
productKey: item.productKey,
exchangeCode: item.exchangeCode,
productCodeUnique: productCodeUnique || item.productCode,
productCode,
productCodeUnique: productCodeUnique || productCode,
}
}
@@ -942,6 +951,27 @@ function formatExecutionAmount(row: ScheduleInvestmentPlanExecutionRecord) {
return amount > 0 ? formatHkd(amount) : '—'
}
function formatExecutionFilledQty(row: ScheduleInvestmentPlanExecutionRecord) {
if (row.filledQty === null || row.filledQty === undefined || row.filledQty === '') {
return '—'
}
return t('recurring.previewQuantity', { n: parsePlanDecimal(row.filledQty) })
}
function formatExecutionFilledAmount(row: ScheduleInvestmentPlanExecutionRecord) {
if (row.filledAmount === null || row.filledAmount === undefined || row.filledAmount === '') {
return '—'
}
return formatHkd(parsePlanDecimal(row.filledAmount))
}
function formatExecutionAvgFilledPrice(row: ScheduleInvestmentPlanExecutionRecord) {
if (row.avgFilledPrice === null || row.avgFilledPrice === undefined || row.avgFilledPrice === '') {
return '—'
}
return formatHkd(parsePlanDecimal(row.avgFilledPrice))
}
function closePlanDetail() {
detailVisible.value = false
}
@@ -1537,9 +1567,9 @@ const previewOffset = computed(() =>
<el-dialog
v-model="detailVisible"
:title="t('recurring.planDetail.title')"
width="min(520px, 92vw)"
:width="detailDialogWidth"
align-center
class="ri-dialog"
class="ri-dialog ri-dialog--plan-detail"
@closed="onDetailDialogClosed"
>
<div v-loading="detailLoading">
@@ -1573,32 +1603,78 @@ const previewOffset = computed(() =>
</dl>
<template v-if="detailPlan && !detailLoading">
<h3 class="detail-exec-title">{{ t('recurring.planDetail.recentExecutions') }}</h3>
<section class="detail-exec-block" aria-labelledby="detail-exec-heading">
<h3 id="detail-exec-heading" class="detail-exec-title">
{{ t('recurring.planDetail.recentExecutions') }}
</h3>
<p v-if="!detailExecutions.length" class="detail-exec-empty">
{{ t('recurring.planDetail.executionEmpty') }}
</p>
<div v-else class="app-table-scroll detail-exec-scroll">
<div v-else-if="isMobile" class="detail-exec-cards">
<article
v-for="(row, index) in detailExecutions"
:key="row.id ?? row.executionNo ?? index"
class="detail-exec-card"
>
<div class="detail-exec-card__head">
<span class="num">{{ row.scheduledExecutionDate || '—' }}</span>
<span class="detail-exec-card__status">{{ row.executionStatus || '—' }}</span>
</div>
<dl class="detail-exec-card__meta">
<div>
<dt>{{ t('recurring.planDetail.colAmount') }}</dt>
<dd class="num">{{ formatExecutionAmount(row) }}</dd>
</div>
<div>
<dt>{{ t('recurring.planDetail.colFilledQty') }}</dt>
<dd class="num">{{ formatExecutionFilledQty(row) }}</dd>
</div>
<div>
<dt>{{ t('recurring.planDetail.colFilledAmount') }}</dt>
<dd class="num">{{ formatExecutionFilledAmount(row) }}</dd>
</div>
<div>
<dt>{{ t('recurring.planDetail.colAvgFilledPrice') }}</dt>
<dd class="num">{{ formatExecutionAvgFilledPrice(row) }}</dd>
</div>
</dl>
</article>
</div>
<div v-else class="detail-exec-table-wrap">
<div class="app-table-scroll detail-exec-scroll">
<table class="plan-table detail-exec-table">
<thead>
<tr>
<th class="l">{{ t('recurring.planDetail.colScheduledDate') }}</th>
<th class="l">{{ t('recurring.planDetail.colActualDate') }}</th>
<th class="l detail-exec-table__sticky">
{{ t('recurring.planDetail.colScheduledDate') }}
</th>
<th>{{ t('recurring.planDetail.colAmount') }}</th>
<th>{{ t('recurring.planDetail.colFilledQty') }}</th>
<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>
<tr v-for="(row, index) in detailExecutions" :key="row.id ?? row.executionNo ?? index">
<td class="l num">{{ row.scheduledExecutionDate || '—' }}</td>
<td class="l num">{{ row.actualTradeDate || '—' }}</td>
<tr
v-for="(row, index) in detailExecutions"
:key="row.id ?? row.executionNo ?? index"
>
<td class="l num detail-exec-table__sticky">
{{ row.scheduledExecutionDate || '—' }}
</td>
<td class="num">{{ formatExecutionAmount(row) }}</td>
<td class="num">{{ formatExecutionFilledQty(row) }}</td>
<td class="num">{{ formatExecutionFilledAmount(row) }}</td>
<td class="num">{{ formatExecutionAvgFilledPrice(row) }}</td>
<td class="l">{{ row.executionStatus || '—' }}</td>
<td class="l">{{ row.orderStatus || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<p class="detail-exec-scroll-hint">{{ t('recurring.planDetail.executionTableScrollHint') }}</p>
</div>
</section>
</template>
</div>
<template #footer>
@@ -1931,8 +2007,14 @@ const previewOffset = computed(() =>
}
}
.detail-exec-block {
margin-top: 4px;
padding-top: 14px;
border-top: 1px solid var(--color-border);
}
.detail-exec-title {
margin: 18px 0 8px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
@@ -1946,12 +2028,138 @@ const previewOffset = computed(() =>
color: var(--color-muted);
}
.detail-exec-scroll {
.detail-exec-cards {
display: flex;
flex-direction: column;
gap: 0;
margin: 0 -4px;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
}
.detail-exec-table {
min-width: 480px;
.detail-exec-card {
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
&:last-child {
border-bottom: none;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
}
&__status {
flex-shrink: 0;
max-width: 50%;
text-align: right;
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
}
&__meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
margin: 0;
dt {
color: var(--color-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.3;
}
dd {
margin: 2px 0 0;
font-size: 13px;
word-break: break-word;
}
}
}
.detail-exec-table-wrap {
margin: 0 -6px;
@include respond-up(md) {
margin: 0 -4px;
}
}
.detail-exec-scroll {
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
}
.detail-exec-scroll-hint {
display: none;
margin: 8px 4px 0;
font-size: 11px;
line-height: 1.4;
text-align: center;
color: var(--color-muted);
@include respond-between(md, lg) {
display: block;
}
}
.plan-table.detail-exec-table {
min-width: 780px;
table-layout: auto;
th,
td {
padding: 8px 10px;
}
th {
font-size: 11px;
letter-spacing: 0.05em;
line-height: 1.35;
white-space: normal;
vertical-align: bottom;
}
td {
font-size: 12px;
}
.detail-exec-table__sticky {
position: sticky;
left: 0;
z-index: 1;
min-width: 6.5rem;
box-shadow: 4px 0 8px -4px rgb(0 0 0 / 12%);
[data-theme='dark'] &,
html.dark & {
box-shadow: 4px 0 8px -4px rgb(0 0 0 / 35%);
}
}
thead .detail-exec-table__sticky {
background: var(--color-surface-2);
}
tbody .detail-exec-table__sticky {
background: var(--color-surface);
}
tbody tr:hover .detail-exec-table__sticky {
background: var(--color-surface-2);
}
}
.form-body {