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 @@ -交易记录 · 南方财富

交易记录

按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。

委托记录

+交易记录 · 南方财富

交易记录

按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。

委托记录

diff --git a/app/static/portal/customer/orders/orders.js b/app/static/portal/customer/orders/orders.js index 3cf79ff..601d9aa 100644 --- a/app/static/portal/customer/orders/orders.js +++ b/app/static/portal/customer/orders/orders.js @@ -1,8 +1,72 @@ import { setupCustomerListPage } from '/static/portal/common/customer-list-page.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; import { escapeHtml, formatCurrency, formatDateTime, formatNumber } from '/static/portal/common/formatters.js'; import { renderEmpty } from '/static/portal/common/state-view.js'; -setupCustomerListPage({ active: 'orders', endpointId: 'T003', getNextCursor: (_data, meta) => meta.next_cursor, render(container, orders) { - if (!orders.length) { renderEmpty(container, '暂无委托记录', '提交模拟委托后,记录会显示在这里。'); return; } - container.innerHTML = `
${orders.map((item) => ``).join('')}
委托编号基金方向委托份额成交价格状态提交时间
${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)}
`; -} }); +// 委托详情(T004):点行内「详情」展开,不跳页。 +// +// 为什么补它:后端 T004 一直存在,但本页只接了 T003(列表),客户**点不进任何一条委托** +// —— 看不到成交价、费用与行情来源。成交明细页(T008)是同一个缺口。 +const DETAIL_ROWS = [ + ['委托编号', (item) => item.order_no], + ['基金', (item) => `${item.product_name || '--'}(${item.product_code || '--'})`], + ['方向', (item) => (item.order_side === 'buy' ? '买入' : '卖出')], + ['价格类型', (item) => (item.price_type === 'market' ? '市价' : item.price_type || '--')], + ['委托数量', (item) => formatNumber(item.quantity, 4)], + ['成交数量', (item) => formatNumber(item.filled_quantity, 4)], + ['行情价', (item) => formatCurrency(item.quote_price)], + ['成交均价', (item) => formatCurrency(item.average_executed_price)], + ['状态', (item) => item.status], + ['提交时间', (item) => formatDateTime(item.submitted_at)], + ['行情时间', (item) => formatDateTime(item.quote_at)], + ['行情来源', (item) => item.quote_source || '--'], +]; + +const COLUMN_COUNT = 8; + +function detailHtml(item) { + return `
${DETAIL_ROWS.map(([label, pick]) => + `
${escapeHtml(label)}${escapeHtml(String(pick(item) ?? '--'))}
`).join('')}
`; +} + +async function toggleDetail(container, orderNo, button) { + const opened = container.querySelector(`[data-detail-for="${CSS.escape(orderNo)}"]`); + if (opened) { + opened.remove(); + button.textContent = '详情'; + return; + } + button.disabled = true; + button.textContent = '加载中'; + try { + const response = await apiClient.get('T004', { pathParams: { orderNo } }); + const row = button.closest('tr'); + row.insertAdjacentHTML( + 'afterend', + `${detailHtml(response.data ?? {})}`, + ); + button.textContent = '收起'; + } catch (error) { + // 详情取不到时**不整页报错**:列表本身是好的,只恢复按钮并记一次错误。 + apiClient.reportError(error); + button.textContent = '详情'; + } finally { + button.disabled = false; + } +} + +setupCustomerListPage({ + active: 'orders', + endpointId: 'T003', + getNextCursor: (_data, meta) => meta.next_cursor, + render(container, orders) { + if (!orders.length) { + renderEmpty(container, '暂无委托记录', '提交模拟委托后,记录会显示在这里。'); + return; + } + container.innerHTML = `
${orders.map((item) => ``).join('')}
委托编号基金方向委托份额成交价格状态提交时间操作
${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)}
`; + container.querySelectorAll('[data-order-detail]').forEach((button) => { + button.addEventListener('click', () => toggleDetail(container, button.dataset.orderDetail, button)); + }); + }, +}); diff --git a/app/static/portal/customer/transactions/index.html b/app/static/portal/customer/transactions/index.html index c7d7b18..f5d5adc 100644 --- a/app/static/portal/customer/transactions/index.html +++ b/app/static/portal/customer/transactions/index.html @@ -1 +1 @@ -成交明细 · 南方财富

成交明细

成交价格、份额、费用与净额均来自 T007 后端记录。

成交记录

+成交明细 · 南方财富

成交明细

成交价格、份额、费用与净额均来自 T007 后端记录。

成交记录

diff --git a/app/static/portal/customer/transactions/transactions.js b/app/static/portal/customer/transactions/transactions.js index 42089be..aef1c83 100644 --- a/app/static/portal/customer/transactions/transactions.js +++ b/app/static/portal/customer/transactions/transactions.js @@ -1,9 +1,74 @@ import { setupCustomerListPage } from '/static/portal/common/customer-list-page.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; import { escapeHtml, formatCurrency, formatDateTime, formatNumber } from '/static/portal/common/formatters.js'; import { renderEmpty } from '/static/portal/common/state-view.js'; -setupCustomerListPage({ active: 'transactions', endpointId: 'T007', getNextCursor: (data) => data?.next_cursor, render(container, data) { - const items = data?.transactions || []; - if (!items.length) { renderEmpty(container, '暂无成交记录', '成交完成后可在这里核对价格、份额与费用。'); return; } - container.innerHTML = `
${items.map((item) => ``).join('')}
成交编号基金方向成交份额成交价成交金额费用净额成交时间
${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)}
`; -} }); +// 成交详情(T008):点行内「详情」展开,不跳页。与委托页(T004)同一个缺口、同一套做法。 +const DETAIL_ROWS = [ + ['成交编号', (item) => item.transaction_no], + ['关联委托', (item) => item.order_no], + ['基金', (item) => `${item.product_name || '--'}(${item.product_code || '--'})`], + ['方向', (item) => (item.order_side === 'buy' ? '买入' : '卖出')], + ['业务类型', (item) => item.transaction_type], + ['成交份额', (item) => formatNumber(item.executed_quantity, 4)], + ['成交价', (item) => formatCurrency(item.executed_price)], + ['单位净值', (item) => formatCurrency(item.nav)], + ['成交金额', (item) => formatCurrency(item.gross_amount)], + ['费率快照', (item) => (item.fee_rate_snapshot === null || item.fee_rate_snapshot === undefined + ? '--' : `${(Number(item.fee_rate_snapshot) * 100).toFixed(4)}%`)], + ['费用', (item) => formatCurrency(item.fee_amount)], + ['净额', (item) => formatCurrency(item.net_amount)], + ['成交时间', (item) => formatDateTime(item.executed_at)], + ['确认时间', (item) => formatDateTime(item.confirmed_at)], + ['是否自动确认', (item) => (item.auto_confirmed ? '是' : '否')], + ['行情来源', (item) => item.quote_source || '--'], +]; + +const COLUMN_COUNT = 10; + +function detailHtml(item) { + return `
${DETAIL_ROWS.map(([label, pick]) => + `
${escapeHtml(label)}${escapeHtml(String(pick(item) ?? '--'))}
`).join('')}
`; +} + +async function toggleDetail(container, transactionNo, button) { + const opened = container.querySelector(`[data-detail-for="${CSS.escape(transactionNo)}"]`); + if (opened) { + opened.remove(); + button.textContent = '详情'; + return; + } + button.disabled = true; + button.textContent = '加载中'; + try { + const response = await apiClient.get('T008', { pathParams: { transactionNo } }); + const row = button.closest('tr'); + row.insertAdjacentHTML( + 'afterend', + `${detailHtml(response.data ?? {})}`, + ); + button.textContent = '收起'; + } catch (error) { + apiClient.reportError(error); + button.textContent = '详情'; + } finally { + button.disabled = false; + } +} + +setupCustomerListPage({ + active: 'transactions', + endpointId: 'T007', + getNextCursor: (data) => data?.next_cursor, + render(container, data) { + const items = data?.transactions || []; + if (!items.length) { + renderEmpty(container, '暂无成交记录', '成交完成后可在这里核对价格、份额与费用。'); + return; + } + container.innerHTML = `
${items.map((item) => ``).join('')}
成交编号基金方向成交份额成交价成交金额费用净额成交时间操作
${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)}
`; + container.querySelectorAll('[data-txn-detail]').forEach((button) => { + button.addEventListener('click', () => toggleDetail(container, button.dataset.txnDetail, button)); + }); + }, +}); diff --git a/app/static/portal/employee-console/workspace/index.html b/app/static/portal/employee-console/workspace/index.html index ea5af9e..5385c18 100644 --- a/app/static/portal/employee-console/workspace/index.html +++ b/app/static/portal/employee-console/workspace/index.html @@ -5,7 +5,7 @@ 平台治理工作台 · 南方财富 - +
@@ -22,6 +22,7 @@ +
@@ -30,17 +31,18 @@
+

详情

确认操作

- + diff --git a/app/static/portal/employee-console/workspace/workspace.css b/app/static/portal/employee-console/workspace/workspace.css index 28170e2..b7e74c1 100644 --- a/app/static/portal/employee-console/workspace/workspace.css +++ b/app/static/portal/employee-console/workspace/workspace.css @@ -13,3 +13,11 @@ .admin-detail-copy { margin: 0; color: var(--ink-soft); line-height: 1.75; white-space: pre-wrap; } @media (max-width: 900px) { .admin-split { grid-template-columns: 1fr; } .admin-split > section + section { border-top: 1px solid var(--line); border-left: 0; } } @media (max-width: 620px) { .admin-identity__form { grid-template-columns: 1fr; } } +/* 内联表单(知识库上传、新建配置版本等):label + 控件 + 按钮排成一行。 */ +.admin-inline-form { padding: var(--space-4); display: flex; align-items: flex-end; flex-wrap: wrap; gap: var(--space-3); border-bottom: 1px solid var(--line); } +.admin-inline-form .form-field { flex: 0 1 240px; } +.admin-inline-form__wide { flex: 1 1 320px; } +.admin-inline-form__status { color: var(--muted); font-size: var(--fs-small); } +/* 版本内容(配置项 + 路由规则):与上方列表留出间隔。 */ +.admin-release-content { margin-top: var(--space-4); display: grid; gap: var(--space-4); } +.admin-release-content__title { margin: 0 0 var(--space-3); font-size: var(--fs-body); font-weight: 600; } diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index cafa9be..a215b71 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -24,15 +24,17 @@ function table(items, columns, action) { if (requireAdmin()) { mountShell({ active: 'admin-workspace', mode: 'admin' }); const context = getAuthContext(); - const state = { roles: [], releases: [], endpoints: [], audits: [], handovers: [], candidates: [], advisor: [], action: null }; + const state = { roles: [], releases: [], endpoints: [], audits: [], handovers: [], candidates: [], advisor: [], knowledge: [], releaseContent: null, action: null }; const targets = { roles: document.querySelector('[data-role-table]'), releases: document.querySelector('[data-release-table]'), + releaseContent: document.querySelector('[data-release-content]'), endpoints: document.querySelector('[data-model-table]'), audits: document.querySelector('[data-audit-table]'), handovers: document.querySelector('[data-handover-table]'), candidates: document.querySelector('[data-candidate-table]'), advisor: document.querySelector('[data-advisor-table]'), + knowledge: document.querySelector('[data-knowledge-table]'), }; const detailDialog = document.querySelector('[data-admin-detail]'); const actionDialog = document.querySelector('[data-admin-action]'); @@ -108,7 +110,9 @@ if (requireAdmin()) { if (item.status === 'draft') actions.push(['validate', '提交校验']); if (item.status === 'pending_review') actions.push(['review', '审核通过']); if (item.status === 'approved') actions.push(['activate', '激活']); - return `
${actions.map(([key, label]) => ``).join('') || '无需操作'}
`; + // 「内容」与状态**无关**:草稿阶段就得能往里加配置项,否则新建的版本永远是空的、 + // 校验必然失败。它单独用 `data-release-content-id`,与状态流转按钮分开绑定。 + return `
${actions.map(([key, label]) => ``).join('')}
`; } async function loadReleases() { @@ -120,6 +124,7 @@ if (requireAdmin()) { else { targets.releases.innerHTML = table(state.releases, [['release_no', '发布编号'], ['title', '标题'], ['status', '状态'], ['created_by', '创建人'], ['updated_at', '更新时间']], releaseActions); targets.releases.querySelectorAll('[data-release-action]').forEach((button) => button.addEventListener('click', () => openReleaseAction(button.dataset.releaseAction, button.dataset.releaseId))); + targets.releases.querySelectorAll('[data-release-content-id]').forEach((button) => button.addEventListener('click', () => loadReleaseContent(button.dataset.releaseContentId))); } } catch (error) { apiClient.reportError(error); renderError(targets.releases, error, loadReleases); } } @@ -232,6 +237,269 @@ if (requireAdmin()) { ); } + // ---- 知识库管理(K002 上传 / K003 列表 / K004 失效)---- + // + // 为什么补它:客服的**全部回答都来自已入库的知识**,而此前没有任何页面能管理知识库 —— + // 只能靠命令行脚本(`tools/seed_knowledge_demo.py`)灌数据,管理员既看不到也改不了。 + async function loadKnowledge() { + renderLoading(targets.knowledge, 3); + try { + const response = await apiClient.get('K003', { query: { limit: 50 } }); + // ⚠️ K003 的成功体是**裸的** `{items, count}`,没有 `data` 信封 —— + // `request()` 仍会去取 `payload.data`,那里是 undefined。所以两面都兜一下, + // 否则列表永远显示"知识库为空",而库里其实有数据。 + const payload = response.data ?? response; + state.knowledge = Array.isArray(payload?.items) ? payload.items : []; + if (!state.knowledge.length) { + renderEmpty(targets.knowledge, '知识库为空', '上传文档后,客服才能据此作答。'); + return; + } + targets.knowledge.innerHTML = table( + state.knowledge, + [['knowledge_id', '知识 ID'], ['title', '标题'], ['knowledge_type', '类型'], ['content_length', '字符数'], ['status', '状态']], + (item) => `
`, + ); + targets.knowledge.querySelectorAll('[data-knowledge-delete]').forEach((button) => { + button.addEventListener('click', () => deleteKnowledge(button.dataset.knowledgeDelete, button)); + }); + } catch (error) { apiClient.reportError(error); renderError(targets.knowledge, error, loadKnowledge); } + } + + /** 读成纯 base64(`readAsDataURL` 给的带 `data:...;base64,` 前缀,后端要的是后半段)。 */ + function readAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('文件读取失败')); + reader.onload = () => resolve(String(reader.result || '').split(',')[1] || ''); + reader.readAsDataURL(file); + }); + } + + async function submitKnowledge(event) { + event.preventDefault(); + const form = event.currentTarget; + const status = form.querySelector('[data-knowledge-status]'); + const submit = form.querySelector('[type="submit"]'); + const file = form.elements.file.files?.[0]; + if (!file) { showToast('请选择文档文件', 'error'); return; } + submit.disabled = true; + status.textContent = '正在上传…'; + try { + const response = await apiClient.post('K002', { + filename: file.name, + content_base64: await readAsBase64(file), + knowledge_type: form.elements.knowledge_type.value, + }); + const ids = response.data?.knowledge_ids || []; + status.textContent = `已入库 ${ids.length} 块`; + showToast(`《${file.name}》已上传,切分入库 ${ids.length} 块`); + form.reset(); + await loadKnowledge(); + } catch (error) { + apiClient.reportError(error); + status.textContent = ''; + showToast(error.message || '上传失败,请检查权限或文件格式', 'error'); + } finally { submit.disabled = false; } + } + + async function deleteKnowledge(knowledgeId, button) { + button.disabled = true; + try { + // K004 是 DELETE,且要求 `Idempotency-Key`(端点表里已标 idempotent) + await apiClient.del('K004', { pathParams: { knowledgeId } }); + showToast('该条知识已失效,向量同步事件已投出'); + await loadKnowledge(); + renderMetrics(); + } catch (error) { + apiClient.reportError(error); + showToast(error.message || '操作未完成', 'error'); + button.disabled = false; + } + } + + // ---- 配置版本的内容编辑(A001 新建版本;A008/A009/A010 配置项;A018/A019/A020 路由规则)---- + // + // 为什么补它:此前工作台只能对**已存在**的版本走"校验→审核→激活",既不能新建版本、 + // 也不能往里加配置项 —— 于是新版本永远是空的、校验必然失败;模型路由规则同样 + // 既看不到也改不了。 + const CONFIG_NAMESPACES = ['agent_tools', 'memory', 'relationship', 'runtime', 'fund_market']; + + async function submitRelease(event) { + event.preventDefault(); + const form = event.currentTarget; + const status = form.querySelector('[data-release-status]'); + const submit = form.querySelector('[type="submit"]'); + submit.disabled = true; + status.textContent = '正在创建…'; + try { + const response = await apiClient.post('A001', { + release_no: form.elements.release_no.value.trim(), + title: form.elements.title.value.trim(), + change_summary: form.elements.change_summary.value.trim(), + }); + status.textContent = `已创建(id=${response.data?.id ?? '?'})`; + showToast('配置版本已创建为草稿,接着点「内容」往里加配置项'); + form.reset(); + await Promise.all([loadReleases(), loadAudits()]); + renderMetrics(); + } catch (error) { + apiClient.reportError(error); + status.textContent = ''; + showToast(error.message || '创建失败', 'error'); + } finally { submit.disabled = false; } + } + + async function loadReleaseContent(releaseId) { + renderLoading(targets.releaseContent, 2); + try { + const [itemsResponse, rulesResponse] = await Promise.all([ + apiClient.get('A009', { pathParams: { releaseId } }), + apiClient.get('A019', { pathParams: { releaseId } }), + ]); + state.releaseContent = { + releaseId, + items: Array.isArray(itemsResponse.data) ? itemsResponse.data : [], + rules: Array.isArray(rulesResponse.data) ? rulesResponse.data : [], + editingItemId: null, + editingRuleId: null, + }; + renderReleaseContent(); + } catch (error) { + apiClient.reportError(error); + renderError(targets.releaseContent, error, () => loadReleaseContent(releaseId)); + } + } + + function fillForm(form, values) { + Object.entries(values).forEach(([key, value]) => { + const field = form.elements[key]; + if (field) field.value = value; + }); + } + + function renderReleaseContent() { + const content = state.releaseContent; + if (!content?.releaseId) { targets.releaseContent.innerHTML = ''; return; } + const { releaseId, items, rules } = content; + const itemRows = table( + items, + [['id', '项 ID'], ['namespace', '命名空间'], ['item_key', '键'], ['schema_version', '版本']], + (item) => `
`, + ); + const ruleRows = table( + rules, + [['id', '规则 ID'], ['rule_code', '规则码'], ['agent_type', 'Agent'], ['task_type', '任务'], ['model_policy', '策略'], ['primary_endpoint_id', '主端点']], + (rule) => `
`, + ); + targets.releaseContent.innerHTML = ` +

版本 ${escapeHtml(String(releaseId))} · 配置项

${items.length} 条
${itemRows}
+

版本 ${escapeHtml(String(releaseId))} · 模型路由规则

${rules.length} 条
${ruleRows}
`; + targets.releaseContent.querySelector('[data-item-form]').addEventListener('submit', (event) => submitItem(event, releaseId)); + targets.releaseContent.querySelector('[data-rule-form]').addEventListener('submit', (event) => submitRule(event, releaseId)); + targets.releaseContent.querySelectorAll('[data-edit-item]').forEach((button) => button.addEventListener('click', () => editItem(button.dataset.editItem))); + targets.releaseContent.querySelectorAll('[data-edit-rule]').forEach((button) => button.addEventListener('click', () => editRule(button.dataset.editRule))); + } + + function editItem(itemId) { + const content = state.releaseContent; + const item = (content?.items || []).find((row) => String(row.id) === String(itemId)); + const form = targets.releaseContent.querySelector('[data-item-form]'); + if (!item || !form) return; + content.editingItemId = item.id; + fillForm(form, { + namespace: item.namespace, + item_key: item.item_key, + value_json: JSON.stringify(item.value_json ?? {}), + }); + form.querySelector('[data-item-submit]').textContent = `保存配置项 #${item.id}`; + form.querySelector('[data-item-status]').textContent = '编辑中:保存将覆盖该配置项'; + form.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + function editRule(ruleId) { + const content = state.releaseContent; + const rule = (content?.rules || []).find((row) => String(row.id) === String(ruleId)); + const form = targets.releaseContent.querySelector('[data-rule-form]'); + if (!rule || !form) return; + content.editingRuleId = rule.id; + fillForm(form, { + rule_code: rule.rule_code, + agent_type: rule.agent_type, + task_type: rule.task_type, + model_policy: rule.model_policy, + primary_endpoint_id: rule.primary_endpoint_id, + }); + form.querySelector('[data-rule-submit]').textContent = `保存规则 #${rule.id}`; + form.querySelector('[data-rule-status]').textContent = '编辑中:保存将覆盖该规则'; + form.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + async function submitItem(event, releaseId) { + event.preventDefault(); + const form = event.currentTarget; + const status = form.querySelector('[data-item-status]'); + const submit = form.querySelector('[data-item-submit]'); + let value; + try { + value = JSON.parse(form.elements.value_json.value); + } catch { + // 值必须是合法 JSON —— 与其让后端 422,不如就地拦住并说清哪里不对。 + status.textContent = '「值」不是合法 JSON'; + return; + } + const body = { + namespace: form.elements.namespace.value, + item_key: form.elements.item_key.value.trim(), + value_json: value, + schema_version: '1', + }; + const editingId = state.releaseContent?.editingItemId; + submit.disabled = true; + status.textContent = '正在提交…'; + try { + if (editingId) await apiClient.put('A010', body, { pathParams: { releaseId, itemId: editingId } }); + else await apiClient.post('A008', body, { pathParams: { releaseId } }); + showToast(editingId ? '配置项已更新' : '配置项已新增'); + await loadReleaseContent(releaseId); + await loadAudits(); + } catch (error) { + apiClient.reportError(error); + status.textContent = error.message || '提交失败'; + } finally { submit.disabled = false; } + } + + async function submitRule(event, releaseId) { + event.preventDefault(); + const form = event.currentTarget; + const status = form.querySelector('[data-rule-status]'); + const submit = form.querySelector('[data-rule-submit]'); + const body = { + rule_code: form.elements.rule_code.value.trim(), + agent_type: form.elements.agent_type.value.trim(), + task_type: form.elements.task_type.value.trim(), + model_policy: form.elements.model_policy.value.trim(), + primary_endpoint_id: Number(form.elements.primary_endpoint_id.value), + // fallbacks 留空:界面上暂不做多级兜底编辑,需要时用接口补。 + fallbacks: [], + max_attempts: 2, + latency_budget_ms: 15000, + priority: 100, + }; + const editingId = state.releaseContent?.editingRuleId; + submit.disabled = true; + status.textContent = '正在提交…'; + try { + if (editingId) await apiClient.put('A020', body, { pathParams: { releaseId, ruleId: editingId } }); + else await apiClient.post('A018', body, { pathParams: { releaseId } }); + showToast(editingId ? '路由规则已更新' : '路由规则已新增'); + await loadReleaseContent(releaseId); + await loadAudits(); + } catch (error) { + apiClient.reportError(error); + status.textContent = error.message || '提交失败'; + } finally { submit.disabled = false; } + } + function openReleaseAction(action, releaseId) { const config = { validate: { title: '提交配置校验', copy: '校验通过后,配置版本将进入待审核状态。', endpoint: 'A004', body: {} }, @@ -316,6 +584,9 @@ if (requireAdmin()) { document.querySelector('[data-identity-form]').addEventListener('submit', queryIdentity); document.querySelector('[data-reload-audit]').addEventListener('click', loadAudits); document.querySelector('[data-reload-advisor]').addEventListener('click', loadAdvisorReviews); + document.querySelector('[data-reload-knowledge]').addEventListener('click', loadKnowledge); + document.querySelector('[data-knowledge-form]').addEventListener('submit', submitKnowledge); + document.querySelector('[data-release-form]').addEventListener('submit', submitRelease); document.querySelector('[data-admin-action-form]').addEventListener('submit', submitAdminAction); document.querySelectorAll('[data-close-detail]').forEach((button) => button.addEventListener('click', () => detailDialog.close())); document.querySelectorAll('[data-close-admin-action]').forEach((button) => button.addEventListener('click', () => actionDialog.close())); @@ -323,7 +594,7 @@ if (requireAdmin()) { async function initialize() { document.querySelector('[data-admin-metrics]').innerHTML = Array.from({ length: 4 }, () => '
').join(''); try { await hydrateIdentity(); } catch (error) { apiClient.reportError(error); showToast(error.message || '权限加载失败', 'error'); } - await Promise.all([loadRoles(), loadReleases(), loadEndpoints(), loadAudits(), loadHandovers(), loadCandidates(), loadAdvisorReviews()]); + await Promise.all([loadRoles(), loadReleases(), loadEndpoints(), loadAudits(), loadHandovers(), loadCandidates(), loadAdvisorReviews(), loadKnowledge()]); renderMetrics(); }