Files
group_fqcd_jr/app/static/portal/common/api-client.js
T
lzf_0626 e31420df29 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。
2026-09-14 00:51:13 +08:00

284 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js?v=20260913';
const ENDPOINTS = Object.freeze({
A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false },
V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true },
P001: { method: 'GET', path: '/api/v1/products' },
P002: { method: 'GET', path: '/api/v1/products/{productCode}/nav-history' },
C001: { method: 'POST', path: '/api/v1/conversations', idempotent: true },
C002: { method: 'GET', path: '/api/v1/conversations/{sessionId}' },
C003: { method: 'GET', path: '/api/v1/conversations/{sessionId}/messages' },
C005: { method: 'POST', path: '/api/v1/conversations/{sessionId}/handover-requests', idempotent: true },
A035: { method: 'GET', path: '/api/v1/admin/roles' },
A036: { method: 'GET', path: '/api/v1/admin/roles/{roleCode}' },
A037: { method: 'GET', path: '/api/v1/admin/roles/{roleCode}/permissions' },
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 },
A006: { method: 'POST', path: '/api/v1/admin/config-releases/{releaseId}/activations', idempotent: true },
A012: { method: 'GET', path: '/api/v1/admin/model-endpoints' },
A033: { method: 'GET', path: '/api/v1/admin/audit-records' },
ADMIN_HANDOVERS: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets' },
ADMIN_HANDOVER_DETAIL: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}' },
ADMIN_ADVISOR_PENDING: { method: 'GET', path: '/api/v1/admin/advisor/pending-contents' },
ADMIN_ADVISOR_REVIEW: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/reviews', idempotent: true },
ADMIN_ADVISOR_PUBLISH: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/publications', idempotent: true },
ONB001: { method: 'GET', path: '/api/v1/onboarding/risk-questionnaire' },
ONB002: { method: 'POST', path: '/api/v1/onboarding/risk-questionnaire/submissions', idempotent: true },
R001: { method: 'POST', path: '/api/v1/agent-runs' },
R002: { method: 'GET', path: '/api/v1/agent-runs/{runId}' },
R003: { method: 'GET', path: '/api/v1/agent-runs/{runId}/events', stream: true },
RK001: { method: 'GET', path: '/api/v1/risk/overview' },
RK002: { method: 'GET', path: '/api/v1/risk/alerts' },
RK003: { method: 'GET', path: '/api/v1/risk/alerts/{alertNo}' },
RK004: { method: 'GET', path: '/api/v1/risk/evidence/{source}' },
RK005: { method: 'GET', path: '/api/v1/risk/notifications' },
RK006: { method: 'POST', path: '/api/v1/risk/alerts/scan', idempotent: true, timeout: 60000 },
RK007: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/acknowledgements', idempotent: true },
RK008: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/investigations', idempotent: true },
RK009: { method: 'POST', path: '/api/v1/risk/alerts/{alertNo}/exclusions', idempotent: true },
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' },
T001: { method: 'GET', path: '/api/v1/users/me/account/dashboard' },
T002: { method: 'POST', path: '/api/v1/users/me/orders', idempotent: true },
T003: { method: 'GET', path: '/api/v1/users/me/orders' },
T004: { method: 'GET', path: '/api/v1/users/me/orders/{orderNo}' },
T005: { method: 'POST', path: '/api/v1/users/me/orders/{orderNo}/cancellations', idempotent: true },
T006: { method: 'GET', path: '/api/v1/users/me/holdings' },
T007: { method: 'GET', path: '/api/v1/users/me/transactions' },
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' },
ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true },
ADVISOR_CREATE_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals', idempotent: true },
ADVISOR_CUSTOMER_GOAL: { method: 'GET', path: '/api/v1/advisor/customers/{customerId}/investment-goals/current' },
ADVISOR_CONFIRM_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/confirmations', idempotent: true },
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' },
});
export class ApiError extends Error {
constructor(message, options = {}) {
super(message);
this.name = 'ApiError';
this.code = options.code || 'NETWORK_ERROR';
this.status = options.status || 0;
this.retryable = Boolean(options.retryable);
this.fieldErrors = options.fieldErrors || [];
this.traceId = options.traceId || '';
}
}
function pathFor(endpoint, pathParams = {}) {
return Object.entries(pathParams).reduce(
(path, [key, value]) => path.replace(`{${key}}`, encodeURIComponent(String(value))),
endpoint.path,
);
}
function wait(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function shouldRetry(error, attempt) {
if (attempt > 0) return false;
return error.status >= 500 || error.status === 0 || error.retryable;
}
async function request(endpointId, options = {}) {
const endpoint = ENDPOINTS[endpointId];
if (!endpoint) throw new ApiError(`未注册端点 ${endpointId}`, { code: 'ENDPOINT_NOT_REGISTERED' });
const traceId = crypto.randomUUID();
document.documentElement.dataset.traceId = traceId;
const query = new URLSearchParams();
Object.entries(options.query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
});
const queryString = query.size ? `?${query.toString()}` : '';
const headers = { Accept: 'application/json', 'X-Trace-ID': traceId, ...(options.headers || {}) };
const token = getAccessToken();
if (endpoint.auth !== false && token) headers.Authorization = `Bearer ${token}`;
if (options.body !== undefined && !endpoint.formData) headers['Content-Type'] = 'application/json';
if (endpoint.idempotent) headers['Idempotency-Key'] = options.idempotencyKey || crypto.randomUUID().replaceAll('-', '');
for (let attempt = 0; attempt < 2; attempt += 1) {
const controller = new AbortController();
const abortListener = () => controller.abort();
options.signal?.addEventListener('abort', abortListener, { once: true });
const timeoutId = window.setTimeout(
() => controller.abort(),
options.timeout || endpoint.timeout || 8000,
);
try {
const response = await fetch(`${pathFor(endpoint, options.pathParams)}${queryString}`, {
method: endpoint.method,
headers,
body: options.body === undefined || endpoint.method === 'GET'
? undefined
: (endpoint.formData ? options.body : JSON.stringify(options.body)),
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && endpoint.auth !== false) {
clearAuthSession({ eventType: 'session-expired' });
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
}
const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId;
document.documentElement.dataset.traceId = responseTraceId;
if (!response.ok || payload.error) {
const detail = payload.error || {};
const validationDetail = Array.isArray(payload.detail)
? payload.detail
.map((item) => item?.msg || item?.message || '')
.filter(Boolean)
.join(';')
: (typeof payload.detail === 'string' ? payload.detail : '');
const message = detail.message
|| validationDetail
|| (response.status ? `请求失败(HTTP ${response.status})` : '请求未完成');
const error = new ApiError(message, {
code: detail.code,
status: response.status,
retryable: detail.retryable,
fieldErrors: detail.field_errors,
traceId: responseTraceId,
});
if (response.status === 429 && attempt === 0) await wait(5000);
else if (shouldRetry(error, attempt)) await wait(2000);
else throw error;
continue;
}
return { data: endpoint.raw ? payload : payload.data, meta: payload.meta || {}, traceId: responseTraceId };
} catch (caught) {
const error = caught instanceof ApiError
? caught
: new ApiError(caught?.name === 'AbortError' ? '请求超时,请检查网络后重试' : '网络连接失败', { traceId });
if (!shouldRetry(error, attempt)) throw error;
await wait(2000);
} finally {
window.clearTimeout(timeoutId);
options.signal?.removeEventListener('abort', abortListener);
}
}
throw new ApiError('网络不稳定,请稍后重试', { traceId });
}
async function stream(endpointId, body, options = {}) {
const endpoint = ENDPOINTS[endpointId];
if (!endpoint?.stream) throw new ApiError(`端点 ${endpointId} 不支持流式请求`, { code: 'ENDPOINT_NOT_STREAMABLE' });
const traceId = crypto.randomUUID();
const token = getAccessToken();
const headers = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'X-Trace-ID': traceId,
...(options.headers || {}),
};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(pathFor(endpoint, options.pathParams), {
method: endpoint.method,
headers,
body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
signal: options.signal,
});
if (!response.ok || !response.body) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && endpoint.auth !== false) {
clearAuthSession({ eventType: 'session-expired' });
window.dispatchEvent(new CustomEvent('portal:auth-expired'));
}
const detail = payload.error || {};
throw new ApiError(detail.message || '流式请求未完成', {
code: detail.code,
status: response.status,
traceId: payload.meta?.trace_id || traceId,
});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const dispatch = (block) => {
if (!block.trim() || block.trimStart().startsWith(':')) return;
let eventName = 'message';
const dataLines = [];
block.split(/\r?\n/).forEach((line) => {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
if (!dataLines.length) return;
const raw = dataLines.join('\n');
let data = raw;
try { data = JSON.parse(raw); } catch { /* Plain-text SSE data is valid. */ }
options.onEvent?.({ type: eventName, data });
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() || '';
blocks.forEach(dispatch);
if (done) break;
}
if (buffer.trim()) dispatch(buffer);
}
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) {
window.dispatchEvent(new CustomEvent('portal:error', { detail: { message: error.message, traceId: error.traceId || '' } }));
},
track(eventName, payload = {}) {
window.dispatchEvent(new CustomEvent('portal:track', { detail: { eventName, payload, at: Date.now() } }));
},
});
export { ENDPOINTS };