feat(portal): 补齐四个前端缺口——客户详情、知识库管理、配置项与路由编辑

先审计了全部 **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。
This commit is contained in:
2026-09-14 00:51:13 +08:00
parent 9a5259d787
commit e31420df29
9 changed files with 463 additions and 17 deletions
+31
View File
@@ -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) {
@@ -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); }
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>交易记录 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/customer-list.css"><link rel="stylesheet" href="/static/portal/customer/orders/orders.css"></head><body><main id="main-content" class="page-shell"><div class="page-heading"><div><h1 class="page-heading__title">交易记录</h1><p class="page-heading__description">按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。</p></div><div class="page-heading__actions"><a class="button" href="/portal/customer/transactions/">成交明细</a><button class="button" type="button" data-reload>刷新</button></div></div><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">委托记录</h2></div><div class="panel__body" data-list-content></div><div class="customer-list__footer"><span>每页最多 20 条</span><button class="button" type="button" data-next-page>下一页</button></div></section></main><script type="module" src="/static/portal/customer/orders/orders.js?v=20260913"></script></body></html>
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>交易记录 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/customer-list.css"><link rel="stylesheet" href="/static/portal/customer/orders/orders.css"></head><body><main id="main-content" class="page-shell"><div class="page-heading"><div><h1 class="page-heading__title">交易记录</h1><p class="page-heading__description">按提交时间倒序展示模拟委托,首版市价委托会立即全额成交。</p></div><div class="page-heading__actions"><a class="button" href="/portal/customer/transactions/">成交明细</a><button class="button" type="button" data-reload>刷新</button></div></div><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">委托记录</h2></div><div class="panel__body" data-list-content></div><div class="customer-list__footer"><span>每页最多 20 条</span><button class="button" type="button" data-next-page>下一页</button></div></section></main><script type="module" src="/static/portal/customer/orders/orders.js?v=20260913-2"></script></body></html>
+68 -4
View File
@@ -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 = `<div class="data-table-wrap"><table class="data-table"><thead><tr><th>委托编号</th><th>基金</th><th>方向</th><th>委托份额</th><th>成交价格</th><th>状态</th><th>提交时间</th></tr></thead><tbody>${orders.map((item) => `<tr><td>${escapeHtml(item.order_no)}</td><td><span class="data-table__primary">${escapeHtml(item.product_name)}</span><span class="data-table__secondary">${escapeHtml(item.product_code)}</span></td><td><span class="tag ${item.order_side === 'sell' ? 'tag--accent' : ''}">${item.order_side === 'buy' ? '买入' : '卖出'}</span></td><td>${formatNumber(item.quantity, 4)}</td><td>${formatCurrency(item.average_executed_price || item.quote_price)}</td><td>${escapeHtml(item.status)}</td><td>${formatDateTime(item.submitted_at)}</td></tr>`).join('')}</tbody></table></div>`;
} });
// 委托详情(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 `<div class="list-detail">${DETAIL_ROWS.map(([label, pick]) =>
`<div class="list-detail__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(pick(item) ?? '--'))}</strong></div>`).join('')}</div>`;
}
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',
`<tr data-detail-for="${escapeHtml(orderNo)}"><td colspan="${COLUMN_COUNT}">${detailHtml(response.data ?? {})}</td></tr>`,
);
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 = `<div class="data-table-wrap"><table class="data-table"><thead><tr><th>委托编号</th><th>基金</th><th>方向</th><th>委托份额</th><th>成交价格</th><th>状态</th><th>提交时间</th><th>操作</th></tr></thead><tbody>${orders.map((item) => `<tr><td>${escapeHtml(item.order_no)}</td><td><span class="data-table__primary">${escapeHtml(item.product_name)}</span><span class="data-table__secondary">${escapeHtml(item.product_code)}</span></td><td><span class="tag ${item.order_side === 'sell' ? 'tag--accent' : ''}">${item.order_side === 'buy' ? '买入' : '卖出'}</span></td><td>${formatNumber(item.quantity, 4)}</td><td>${formatCurrency(item.average_executed_price || item.quote_price)}</td><td>${escapeHtml(item.status)}</td><td>${formatDateTime(item.submitted_at)}</td><td><button class="button button--quiet" type="button" data-order-detail="${escapeHtml(item.order_no)}">详情</button></td></tr>`).join('')}</tbody></table></div>`;
container.querySelectorAll('[data-order-detail]').forEach((button) => {
button.addEventListener('click', () => toggleDetail(container, button.dataset.orderDetail, button));
});
},
});
@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>成交明细 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/customer-list.css"><link rel="stylesheet" href="/static/portal/customer/transactions/transactions.css"></head><body><main id="main-content" class="page-shell"><div class="page-heading"><div><h1 class="page-heading__title">成交明细</h1><p class="page-heading__description">成交价格、份额、费用与净额均来自 T007 后端记录。</p></div><div class="page-heading__actions"><button class="button" type="button" data-reload>刷新</button></div></div><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">成交记录</h2></div><div class="panel__body" data-list-content></div><div class="customer-list__footer"><span>每页最多 20 条</span><button class="button" type="button" data-next-page>下一页</button></div></section></main><script type="module" src="/static/portal/customer/transactions/transactions.js?v=20260913"></script></body></html>
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>成交明细 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/customer-list.css"><link rel="stylesheet" href="/static/portal/customer/transactions/transactions.css"></head><body><main id="main-content" class="page-shell"><div class="page-heading"><div><h1 class="page-heading__title">成交明细</h1><p class="page-heading__description">成交价格、份额、费用与净额均来自 T007 后端记录。</p></div><div class="page-heading__actions"><button class="button" type="button" data-reload>刷新</button></div></div><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">成交记录</h2></div><div class="panel__body" data-list-content></div><div class="customer-list__footer"><span>每页最多 20 条</span><button class="button" type="button" data-next-page>下一页</button></div></section></main><script type="module" src="/static/portal/customer/transactions/transactions.js?v=20260913-2"></script></body></html>
@@ -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 = `<div class="data-table-wrap"><table class="data-table"><thead><tr><th>成交编号</th><th>基金</th><th>方向</th><th>成交份额</th><th>成交价</th><th>成交金额</th><th>费用</th><th>净额</th><th>成交时间</th></tr></thead><tbody>${items.map((item) => `<tr><td><span class="data-table__primary">${escapeHtml(item.transaction_no)}</span><span class="data-table__secondary">委托 ${escapeHtml(item.order_no)}</span></td><td><span class="data-table__primary">${escapeHtml(item.product_name)}</span><span class="data-table__secondary">${escapeHtml(item.product_code)}</span></td><td>${item.order_side === 'buy' ? '买入' : '卖出'}</td><td>${formatNumber(item.executed_quantity, 4)}</td><td>${formatCurrency(item.executed_price)}</td><td>${formatCurrency(item.gross_amount)}</td><td>${formatCurrency(item.fee_amount)}</td><td>${formatCurrency(item.net_amount)}</td><td>${formatDateTime(item.executed_at)}</td></tr>`).join('')}</tbody></table></div>`;
} });
// 成交详情(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 `<div class="list-detail">${DETAIL_ROWS.map(([label, pick]) =>
`<div class="list-detail__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(pick(item) ?? '--'))}</strong></div>`).join('')}</div>`;
}
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',
`<tr data-detail-for="${escapeHtml(transactionNo)}"><td colspan="${COLUMN_COUNT}">${detailHtml(response.data ?? {})}</td></tr>`,
);
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 = `<div class="data-table-wrap"><table class="data-table"><thead><tr><th>成交编号</th><th>基金</th><th>方向</th><th>成交份额</th><th>成交价</th><th>成交金额</th><th>费用</th><th>净额</th><th>成交时间</th><th>操作</th></tr></thead><tbody>${items.map((item) => `<tr><td><span class="data-table__primary">${escapeHtml(item.transaction_no)}</span><span class="data-table__secondary">委托 ${escapeHtml(item.order_no)}</span></td><td><span class="data-table__primary">${escapeHtml(item.product_name)}</span><span class="data-table__secondary">${escapeHtml(item.product_code)}</span></td><td>${item.order_side === 'buy' ? '买入' : '卖出'}</td><td>${formatNumber(item.executed_quantity, 4)}</td><td>${formatCurrency(item.executed_price)}</td><td>${formatCurrency(item.gross_amount)}</td><td>${formatCurrency(item.fee_amount)}</td><td>${formatCurrency(item.net_amount)}</td><td>${formatDateTime(item.executed_at)}</td><td><button class="button button--quiet" type="button" data-txn-detail="${escapeHtml(item.transaction_no)}">详情</button></td></tr>`).join('')}</tbody></table></div>`;
container.querySelectorAll('[data-txn-detail]').forEach((button) => {
button.addEventListener('click', () => toggleDetail(container, button.dataset.txnDetail, button));
});
},
});
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>平台治理工作台 · 南方财富</title>
<link rel="stylesheet" href="/static/portal/common/base.css">
<link rel="stylesheet" href="/static/portal/employee-console/workspace/workspace.css?v=20260913-2">
<link rel="stylesheet" href="/static/portal/employee-console/workspace/workspace.css?v=20260913-3">
</head>
<body>
<main id="main-content" class="page-shell operations-shell">
@@ -22,6 +22,7 @@
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="handover">转人工工单</button>
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="candidates">画像候选</button>
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="advisor">投顾复核</button>
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="knowledge">知识库</button>
</div>
<section class="operations-view admin-view" data-admin-view="rbac">
<div class="admin-split">
@@ -30,17 +31,18 @@
</div>
</section>
<section class="operations-view admin-view" data-admin-view="configuration" hidden>
<section><div class="panel__header"><h2 class="panel__title">配置发布</h2><span class="section-heading__meta">校验、审核与激活均保留审计</span></div><div data-release-table></div></section>
<section><div class="panel__header"><h2 class="panel__title">配置发布</h2><span class="section-heading__meta">校验、审核与激活均保留审计</span></div><form class="admin-inline-form" data-release-form><label class="form-field"><span class="form-field__label">版本号</span><input class="form-field__input" name="release_no" required maxlength="64" placeholder="如 release-20260913"></label><label class="form-field"><span class="form-field__label">标题</span><input class="form-field__input" name="title" required maxlength="128" placeholder="本次发布的内容"></label><label class="form-field admin-inline-form__wide"><span class="form-field__label">变更说明</span><input class="form-field__input" name="change_summary" required maxlength="1000" placeholder="为什么改、影响什么"></label><button class="button button--primary" type="submit">新建配置版本</button><span class="admin-inline-form__status" data-release-status></span></form><div data-release-table></div><div class="admin-release-content" data-release-content></div></section>
<section><div class="panel__header"><h2 class="panel__title">模型端点</h2><span class="section-heading__meta">密钥引用不会在前端暴露</span></div><div data-model-table></div></section>
</section>
<section class="operations-view admin-view" data-admin-view="audit" hidden><div class="panel__header"><h2 class="panel__title">最近审计记录</h2><button class="button table-action" type="button" data-reload-audit>刷新</button></div><div data-audit-table></div></section>
<section class="operations-view admin-view" data-admin-view="handover" hidden><div class="panel__header"><h2 class="panel__title">客服转人工工单</h2><span class="section-heading__meta">仅展示二次脱敏摘要</span></div><div data-handover-table></div></section>
<section class="operations-view admin-view" data-admin-view="candidates" hidden><div class="panel__header"><h2 class="panel__title">客户画像候选</h2><span class="section-heading__meta">审核后方可进入正式记忆</span></div><div data-candidate-table></div></section>
<section class="operations-view admin-view" data-admin-view="advisor" hidden><div class="panel__header"><h2 class="panel__title">待审投顾内容</h2><span class="section-heading__meta">推荐方案与投资方案书;审核通过后再发布</span><button class="button table-action" type="button" data-reload-advisor>刷新</button></div><div data-advisor-table></div></section>
<section class="operations-view admin-view" data-admin-view="knowledge" hidden><div class="panel__header"><h2 class="panel__title">知识库文档</h2><span class="section-heading__meta">上传后自动切分入库并投向量同步;客服据此作答</span><button class="button table-action" type="button" data-reload-knowledge>刷新</button></div><form class="admin-inline-form" data-knowledge-form><label class="form-field"><span class="form-field__label">文档文件</span><input class="form-field__input" type="file" name="file" accept=".txt,.md,.docx" required></label><label class="form-field"><span class="form-field__label">知识类型</span><select class="form-field__input" name="knowledge_type"><option value="faq">faq(问答)</option><option value="product" selected>product(产品资料)</option><option value="policy">policy(制度规则)</option></select></label><button class="button button--primary" type="submit">上传并入库</button><span class="admin-inline-form__status" data-knowledge-status></span></form><div data-knowledge-table></div></section>
</article>
</main>
<dialog class="operations-dialog" data-admin-detail><div class="operations-dialog__header"><h2 data-detail-title>详情</h2><button class="button icon-button" type="button" data-close-detail aria-label="关闭">×</button></div><div class="operations-dialog__body" data-detail-body></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-detail>关闭</button></div></dialog>
<dialog class="operations-dialog" data-admin-action><form data-admin-action-form><div class="operations-dialog__header"><h2 data-admin-action-title>确认操作</h2><button class="button icon-button" type="button" data-close-admin-action aria-label="关闭">×</button></div><div class="operations-dialog__body"><p class="admin-action-copy" data-admin-action-copy></p><label class="form-field" data-admin-comment-field hidden><span class="form-field__label">审核意见</span><textarea class="form-field__input admin-comment" name="comment" maxlength="1000"></textarea></label><div class="form-alert" data-admin-action-alert></div></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-admin-action>取消</button><button class="button button--primary" type="submit">确认提交</button></div></form></dialog>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260913-2"></script>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260913-3"></script>
</body>
</html>
@@ -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; }
@@ -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 `<div class="admin-release-actions">${actions.map(([key, label]) => `<button class="button table-action" type="button" data-release-action="${key}" data-release-id="${escapeHtml(item.id)}">${label}</button>`).join('') || '<span class="value--muted">无需操作</span>'}</div>`;
// 「内容」与状态**无关**:草稿阶段就得能往里加配置项,否则新建的版本永远是空的、
// 校验必然失败。它单独用 `data-release-content-id`,与状态流转按钮分开绑定。
return `<div class="admin-release-actions"><button class="button table-action" type="button" data-release-content-id="${escapeHtml(item.id)}">内容</button>${actions.map(([key, label]) => `<button class="button table-action" type="button" data-release-action="${key}" data-release-id="${escapeHtml(item.id)}">${label}</button>`).join('')}</div>`;
}
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) => `<div class="admin-release-actions"><button class="button button--danger table-action" type="button" data-knowledge-delete="${item.knowledge_id}">失效</button></div>`,
);
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) => `<div class="admin-release-actions"><button class="button table-action" type="button" data-edit-item="${item.id}">编辑</button></div>`,
);
const ruleRows = table(
rules,
[['id', '规则 ID'], ['rule_code', '规则码'], ['agent_type', 'Agent'], ['task_type', '任务'], ['model_policy', '策略'], ['primary_endpoint_id', '主端点']],
(rule) => `<div class="admin-release-actions"><button class="button table-action" type="button" data-edit-rule="${rule.id}">编辑</button></div>`,
);
targets.releaseContent.innerHTML = `
<section class="panel"><div class="panel__header"><h2 class="panel__title">版本 ${escapeHtml(String(releaseId))} · 配置项</h2><span class="section-heading__meta">${items.length} 条</span></div>${itemRows}<form class="admin-inline-form" data-item-form><label class="form-field"><span class="form-field__label">命名空间</span><select class="form-field__input" name="namespace">${CONFIG_NAMESPACES.map((name) => `<option value="${name}">${name}</option>`).join('')}</select></label><label class="form-field"><span class="form-field__label">键名</span><input class="form-field__input" name="item_key" required maxlength="128" placeholder="customer_service:faq"></label><label class="form-field admin-inline-form__wide"><span class="form-field__label">值(JSON)</span><input class="form-field__input" name="value_json" required placeholder='{"allowed_tools":["search_knowledge"]}'></label><button class="button button--primary" type="submit" data-item-submit>新增配置项</button><span class="admin-inline-form__status" data-item-status></span></form></section>
<section class="panel"><div class="panel__header"><h2 class="panel__title">版本 ${escapeHtml(String(releaseId))} · 模型路由规则</h2><span class="section-heading__meta">${rules.length} 条</span></div>${ruleRows}<form class="admin-inline-form" data-rule-form><label class="form-field"><span class="form-field__label">规则码</span><input class="form-field__input" name="rule_code" required maxlength="64"></label><label class="form-field"><span class="form-field__label">Agent</span><input class="form-field__input" name="agent_type" required maxlength="32" placeholder="customer_service"></label><label class="form-field"><span class="form-field__label">任务类型</span><input class="form-field__input" name="task_type" required maxlength="48" placeholder="intent_classify"></label><label class="form-field"><span class="form-field__label">模型策略</span><input class="form-field__input" name="model_policy" required maxlength="32" placeholder="primary_only"></label><label class="form-field"><span class="form-field__label">主端点 ID</span><input class="form-field__input" name="primary_endpoint_id" type="number" min="1" required></label><button class="button button--primary" type="submit" data-rule-submit>新增路由规则</button><span class="admin-inline-form__status" data-rule-status></span></form></section>`;
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 }, () => '<div class="skeleton"></div>').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();
}