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:
@@ -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 @@
|
||||
<!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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user