交易记录
按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。
From e31420df2972e34ee3da67e1537c2575c717c20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 00:51:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal):=20=E8=A1=A5=E9=BD=90=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E5=89=8D=E7=AB=AF=E7=BC=BA=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E8=AF=A6=E6=83=85=E3=80=81=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E7=AE=A1=E7=90=86=E3=80=81=E9=85=8D=E7=BD=AE=E9=A1=B9?= =?UTF-8?q?=E4=B8=8E=E8=B7=AF=E7=94=B1=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先审计了全部 **141 个后端端点**:前端注册 68 个,注册的**全部有效**(没有一个打不通)。 本提交补的是其中真正影响可用性的四类。 ## 1. 客户端:委托详情(T004)与成交详情(T008) 两个接口后端一直存在,但订单页 / 成交明细页**只接了列表(T003 / T007)** —— 客户点不进任何一条记录,看不到成交价、费用构成、确认时间与行情来源。 改为**行内展开**(点「详情」在原行下方展开,再点收起),不跳页: - 复用新加的公共样式 `.list-detail`(`common/customer-list.css`),两页共用而不各写一份 - 详情取不到时**不整页报错**:列表本身是好的,只恢复按钮并记一次错误 ## 2. 管理员:知识库管理(K002 / K003 / K004) 客服的**全部回答都来自已入库的知识**,而此前**没有任何页面能管理知识库** —— 只能靠命令行脚本 `tools/seed_knowledge_demo.py` 灌数据,管理员既看不到也改不了。 新增「知识库」标签页:文档列表 + 上传(.txt/.md/.docx)+ 失效。两处要点: - **K003 的成功体是裸的** `{items, count}`、**没有 `data` 信封** —— `request()` 仍会去取 `payload.data`(那是 undefined),所以列表要两面都兜, 否则永远显示"知识库为空"、而库里其实有数据; - 上传是 **JSON + base64**,不是 multipart(一期契约如此,见 `knowledge_management.py`)。 ## 3. 管理员:配置项与模型路由(A001 / A008–A010 / A018–A020) 此前只能对**已存在**的版本走"校验→审核→激活",**既不能新建版本、也不能往里加配置项** —— 新建的版本永远是空的、校验必然失败;模型路由规则同样既看不到也改不了。 - 新增「新建配置版本」表单(版本号 / 标题 / 变更说明) - 每个版本加「内容」按钮(**与状态无关**:草稿阶段就要能加,否则版本永远空)→ 展开该版本的**配置项**与**模型路由规则**,两者都支持新增与编辑 - 配置项的「值」按 JSON 输入并在前端校验:与其让后端 422,不如就地拦住并说清哪里不对 - `fallbacks` 暂不在界面编辑(提交空数组),需要时用接口补 这些端点**都已在 `docs/05` §19 有编号**,直接复用,无需新增编号。 ## 4. 两个"死端点"查证后**保留** 初查发现 `RK013`(风控日报非流式,已被 RK014 流式取代)与 `ADVISOR_GOAL` (投顾自己的目标;投顾是员工、没有目标 → 永远 404)注册了却无人调用,一度删除。 但 `tests/unit/api/test_portal_frontend.py` 立刻失败 —— 它把"页面会用到的端点" 固定成一张清单,**注册与调用是两件事**。已恢复注册,并就地注明它们当前无人调用、 但受契约保护。 顺带发现:**`RK013`–`RK015`(风控日报)也不在 §19**,与投顾 AD 段原先的情况相同, 属文档缺口(未在本提交内补)。 ## 辅助改动 `apiClient` 增加 `del()` 与 `put()`:真正发出的方法一直由端点表里的 `method` 决定, 所以 `post('K004')` 也能发出 DELETE —— 但读代码的人会以为发的是 POST。 现在意图与行为一致。 验证:unit+contract **1397 passed**(含前端契约 37);integration **110 passed**; ruff 通过;mypy 251 文件 0 错;e2e 冒烟 **40/40**;相关页面与静态资源全部 200。 --- app/static/portal/common/api-client.js | 31 ++ app/static/portal/common/customer-list.css | 5 + app/static/portal/customer/orders/index.html | 2 +- app/static/portal/customer/orders/orders.js | 72 ++++- .../portal/customer/transactions/index.html | 2 +- .../customer/transactions/transactions.js | 75 ++++- .../employee-console/workspace/index.html | 8 +- .../employee-console/workspace/workspace.css | 8 + .../employee-console/workspace/workspace.js | 277 +++++++++++++++++- 9 files changed, 463 insertions(+), 17 deletions(-) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 0029bcb..52ad556 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -15,7 +15,14 @@ const ENDPOINTS = Object.freeze({ A038: { method: 'GET', path: '/api/v1/admin/users/{userId}/roles' }, A039: { method: 'GET', path: '/api/v1/admin/customer-profile-candidates' }, A040: { method: 'POST', path: '/api/v1/admin/customer-profile-candidates/{candidateId}/reviews' }, + A001: { method: 'POST', path: '/api/v1/admin/config-releases', idempotent: true }, A002: { method: 'GET', path: '/api/v1/admin/config-releases' }, + A008: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items', idempotent: true }, + A009: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items' }, + A010: { method: 'PUT', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items/{itemId}', idempotent: true }, + A018: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules', idempotent: true }, + A019: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules' }, + A020: { method: 'PUT', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules/{ruleId}', idempotent: true }, A003: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}' }, A004: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/validations', idempotent: true }, A005: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/reviews', idempotent: true }, @@ -44,6 +51,9 @@ const ENDPOINTS = Object.freeze({ RK010: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/resolutions', idempotent: true }, RK011: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/escalations', idempotent: true }, RK012: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/evidence', formData: true }, + // ⚠️ 保留:同上,前端契约测试要求这张表里有它。风控日报现在走 `RK014`(SSE 流式), + // 非流式这条当前无人调用。另注:RK013–RK015 目前**尚未登记进 `docs/05` §19** + // (与投顾 AD 段原先的情况相同),属于文档缺口。 RK013: { method: 'POST', path: '/api/v1/risk/daily-report' }, RK014: { method: 'POST', path: '/api/v1/risk/daily-report/stream', stream: true }, RK015: { method: 'POST', path: '/api/v1/risk/daily-report/mail' }, @@ -57,6 +67,9 @@ const ENDPOINTS = Object.freeze({ T008: { method: 'GET', path: '/api/v1/users/me/transactions/{transactionNo}' }, T009: { method: 'GET', path: '/api/v1/users/me/cash-ledger' }, ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' }, + // ⚠️ 保留:前端契约测试(`tests/unit/api/test_portal_frontend.py`)把"页面会用到的端点" + // 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用 + // (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。 ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' }, ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis' }, ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation' }, @@ -67,6 +80,9 @@ const ENDPOINTS = Object.freeze({ ADVISOR_GOAL_BOOK: { method: 'GET', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book' }, ADVISOR_REVIEW_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/reviews', idempotent: true }, ADVISOR_PUBLISH_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/publications', idempotent: true }, + K002: { method: 'POST', path: '/api/v1/knowledge/upload' }, + K003: { method: 'GET', path: '/api/v1/knowledge/list' }, + K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true }, OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' }, OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' }, }); @@ -239,6 +255,21 @@ async function stream(endpointId, body, options = {}) { export const apiClient = Object.freeze({ get(endpointId, options = {}) { return request(endpointId, options); }, post(endpointId, body, options = {}) { return request(endpointId, { ...options, body }); }, + /** + * 带请求体的 PUT(更新类端点)。 + * + * 与 `del` 同理:真正发出的方法由端点表里的 `method` 决定,所以 `post('A010')` + * 也会发出 PUT —— 但读代码的人会以为发的是 POST。用它表达"这是更新"。 + */ + put(endpointId, body, options = {}) { return request(endpointId, { ...options, body }); }, + /** + * 无请求体的写方法(DELETE 等)。 + * + * 实际发什么方法由**端点表里的 `method`** 决定(`request()` 用的就是它), + * 所以过去用 `post('K004')` 也能发出 DELETE —— 但读代码的人会以为发的是 POST。 + * 有了这个方法,`del('K004')` 的意图与行为一致。 + */ + del(endpointId, options = {}) { return request(endpointId, options); }, upload(endpointId, formData, options = {}) { return request(endpointId, { ...options, body: formData, timeout: options.timeout || 30000 }); }, stream, reportError(error) { diff --git a/app/static/portal/common/customer-list.css b/app/static/portal/common/customer-list.css index e267e85..e43b2ef 100644 --- a/app/static/portal/common/customer-list.css +++ b/app/static/portal/common/customer-list.css @@ -2,6 +2,11 @@ .customer-list__summary { margin-bottom: var(--space-5); } .customer-list__footer { padding: var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); color: var(--muted); font-size: var(--fs-small); border-top: 1px solid var(--line); } .customer-list__note { margin: 0 0 var(--space-4); color: var(--muted); line-height: 1.7; } +/* 列表行内展开的详情(委托 T004 / 成交 T008)。两个页面共用,避免各写一份。 */ +.list-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 var(--space-4); padding: var(--space-3) var(--space-4); background: var(--surface); } +.list-detail__row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--line); } +.list-detail__row span { color: var(--muted); font-size: var(--fs-small); white-space: nowrap; } +.list-detail__row strong { font-weight: 600; text-align: right; overflow-wrap: anywhere; } .profit-breakdown { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); } .portal-customer .profit-breakdown .metric-card { min-height: 142px; padding: var(--space-5); display: flex; flex-direction: column; justify-content: center; border-color: rgba(183, 201, 194, 0.68); border-radius: 8px; box-shadow: var(--shadow-card); } .portal-customer .customer-list__footer { min-height: 68px; padding-right: var(--space-5); padding-left: var(--space-5); background: rgba(237, 244, 241, 0.36); } diff --git a/app/static/portal/customer/orders/index.html b/app/static/portal/customer/orders/index.html index 9bdfd16..a462ab1 100644 --- a/app/static/portal/customer/orders/index.html +++ b/app/static/portal/customer/orders/index.html @@ -1 +1 @@ -
按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。
按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。
| 委托编号 | 基金 | 方向 | 委托份额 | 成交价格 | 状态 | 提交时间 |
|---|---|---|---|---|---|---|
| ${escapeHtml(item.order_no)} | ${escapeHtml(item.product_name)}${escapeHtml(item.product_code)} | ${item.order_side === 'buy' ? '买入' : '卖出'} | ${formatNumber(item.quantity, 4)} | ${formatCurrency(item.average_executed_price || item.quote_price)} | ${escapeHtml(item.status)} | ${formatDateTime(item.submitted_at)} |
| 委托编号 | 基金 | 方向 | 委托份额 | 成交价格 | 状态 | 提交时间 | 操作 |
|---|---|---|---|---|---|---|---|
| ${escapeHtml(item.order_no)} | ${escapeHtml(item.product_name)}${escapeHtml(item.product_code)} | ${item.order_side === 'buy' ? '买入' : '卖出'} | ${formatNumber(item.quantity, 4)} | ${formatCurrency(item.average_executed_price || item.quote_price)} | ${escapeHtml(item.status)} | ${formatDateTime(item.submitted_at)} |
成交价格、份额、费用与净额均来自 T007 后端记录。
成交价格、份额、费用与净额均来自 T007 后端记录。
| 成交编号 | 基金 | 方向 | 成交份额 | 成交价 | 成交金额 | 费用 | 净额 | 成交时间 |
|---|---|---|---|---|---|---|---|---|
| ${escapeHtml(item.transaction_no)}委托 ${escapeHtml(item.order_no)} | ${escapeHtml(item.product_name)}${escapeHtml(item.product_code)} | ${item.order_side === 'buy' ? '买入' : '卖出'} | ${formatNumber(item.executed_quantity, 4)} | ${formatCurrency(item.executed_price)} | ${formatCurrency(item.gross_amount)} | ${formatCurrency(item.fee_amount)} | ${formatCurrency(item.net_amount)} | ${formatDateTime(item.executed_at)} |
| 成交编号 | 基金 | 方向 | 成交份额 | 成交价 | 成交金额 | 费用 | 净额 | 成交时间 | 操作 |
|---|---|---|---|---|---|---|---|---|---|
| ${escapeHtml(item.transaction_no)}委托 ${escapeHtml(item.order_no)} | ${escapeHtml(item.product_name)}${escapeHtml(item.product_code)} | ${item.order_side === 'buy' ? '买入' : '卖出'} | ${formatNumber(item.executed_quantity, 4)} | ${formatCurrency(item.executed_price)} | ${formatCurrency(item.gross_amount)} | ${formatCurrency(item.fee_amount)} | ${formatCurrency(item.net_amount)} | ${formatDateTime(item.executed_at)} |