Files
group_fqcd_jr/app/static/portal/common/api-client.js
T
lzf_0626 ae7a89f33d 投顾工作台:修推荐结果渲染(幂等响应形状归一)+ 对齐门户版式规范
投顾页「生成推荐方案」拿不到产品的根因:该端点标了 idempotent,浏览器必带
Idempotency-Key,而后端此时返回的是 {data:{content_id, status, plan:{...}}, meta}
—— 真正的文档嵌在 data.plan 里。此前前端只处理了不带键时的裸文档形状。

- api-client:ADVISOR_RECOMMEND 撤掉 raw(带幂等键时确为信封,需正常解包);
  ADVISOR_ALLOCATION / ADVISOR_ANALYSIS 保留 raw(不标幂等,返回裸文档)
- actions-module:新增 normalizeRecommend(),把「信封 + plan 嵌套」与「裸文档」
  两种响应归一成一种形状;结果卡片显示方案编号与待审核状态
- 投顾页:hero 大图 + 4 张概览指标卡 + 左栏/主区两栏构图
- 投顾页:页头常显「退出登录」按钮;新增风评预警数、快捷问句按钮
- 投顾页:动作名统一为「生成推荐方案」(对齐软件需求文档 5.4 ⑦)
- 管理台「待审投顾内容」空态文案同步改名
- 前端模块相对 import 加 ?v= 版本号:改文件内容而 URL 不变会被浏览器缓存挡住
2026-09-14 22:17:39 +08:00

373 lines
23 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({
// 健康检查没有 `data` 信封(是裸的 `{"status": ...}`),所以必须 `raw: true` ——
// 否则调用方拿到 `payload.data`(undefined),会把"后端在线"判成"离线"。
HEALTH: { method: 'GET', path: '/health', auth: false, raw: true },
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 },
A041: { method: 'GET', path: '/api/v1/admin/advisor/profile-drift-reviews' },
A042: { method: 'POST', path: '/api/v1/admin/advisor/profile-drift-reviews/{reviewId}/reviews', 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 },
// 详情端点:**更新必须先拿到这一行的 etag**(PUT 要求 If-Match),
// 而列表的 meta 里没有它 —— 见 `docs/05` §19 的 A048/A049 说明。
A048: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/platform-config-items/{itemId}' },
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 },
A049: { method: 'GET', path: '/api/v1/admin/config-releases/{releaseId}/model-routing-rules/{ruleId}' },
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' },
// ⚠️ 这三个 POST 的响应形状**取决于是否带 `Idempotency-Key`**:
// · 不带键(ALLOCATION / ANALYSIS 的常态)→ **裸业务文档**,顶层键是 `status` / `allocation` / `summary`…,
// 必须标 `raw`,否则 `payload.data` 取到 `undefined`,整包被丢掉(2026-09-14 踩过)。
// · 带键(RECOMMEND 标了 `idempotent`,浏览器必带)→ **`{data, meta}` 信封**,且 `data` 是
// `{content_id, status, plan:{…}}` —— 真正的文档嵌在 `plan` 里(方案已落库待审核)。
// 所以 RECOMMEND **不能**标 `raw`,让 `request()` 正常解包;`plan` 这层嵌套由
// `actions-module` 的 `normalizeRecommend()` 归一。曾把 raw 误加到 RECOMMEND 上,
// 结果信封被当成数据,页面显示「后端返回状态:undefined」。
ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis', raw: true },
ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation', raw: true },
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 / K003 必须标 `raw: true`:它们的成功体是**裸的**(没有 `data` 信封)——
// K002 直接返回 `{knowledge_ids, filename, chunk_count}`,K003 返回 `{items, count}`。
// 不标的话 `request()` 会去取 `payload.data`(undefined),调用方拿到空值:
// 上传显示"已入库 0 块"、列表显示"知识库为空",而库里其实有数据。
// 与 `V001`(访客令牌)同一个道理。
K002: { method: 'POST', path: '/api/v1/knowledge/upload', raw: true },
K003: { method: 'GET', path: '/api/v1/knowledge/list', raw: true },
K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true },
OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' },
OFFSITE_MAIL: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}' },
OFFSITE_MAIL_DELETE: { method: 'POST', path: '/api/v1/offsite-fund/mails/{mailId}/deletions', idempotent: true },
OFFSITE_RECOGNITION: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields' },
OFFSITE_RECOGNITION_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields', idempotent: true },
OFFSITE_NL2SQL_FIELDS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields' },
OFFSITE_NL2SQL_FIELDS_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields', idempotent: true },
OFFSITE_RULE_RESULTS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results' },
OFFSITE_RULE_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results/recalculations', idempotent: true },
OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' },
OFFSITE_MAILBOX_RECOVER: { method: 'POST', path: '/api/v1/offsite-fund/mailbox-status/recoveries', idempotent: true },
OFFSITE_ATTACHMENT_FILE: { method: 'GET', path: '/api/v1/offsite-fund/attachments/{attachmentId}/file' },
OFFSITE_CONFIRM: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/confirmations', idempotent: true },
OFFSITE_RECOGNITION_RETRY: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/recognition-retries', idempotent: true },
OFFSITE_NOTIFICATION_CREATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/notifications', idempotent: true },
OFFSITE_NOTIFICATION_SEND: { method: 'POST', path: '/api/v1/offsite-fund/notifications/{notificationId}/send', idempotent: true },
OFFSITE_SETTLEMENT_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/settlement-statistics/recalculate', idempotent: true },
OFFSITE_TRIGGER_NL2SQL: { method: 'POST', path: '/api/tasks/{taskId}/trigger-agent-nl2sql', idempotent: true },
PROMOTION_CREATE: { method: 'POST', path: '/api/v1/fund-promotion-materials', idempotent: true },
PROMOTION_TASK: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}' },
PROMOTION_INPUTS: { method: 'PUT', path: '/api/v1/fund-promotion-materials/{taskNo}/inputs', idempotent: true },
PROMOTION_ATTACHMENT: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/attachments', formData: true, idempotent: true },
// 阿里云背景图生成采用异步任务,前端等待时间必须覆盖后端的 90 秒上游超时。
PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true, timeout: 120000 },
PROMOTION_CHECKS: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}/compliance-checks' },
PROMOTION_REVIEW: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/reviews', idempotent: true },
PROMOTION_DELIVERY: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/deliveries', idempotent: true },
AGENT_RUN_CREATE: { method: 'POST', path: '/api/v1/agent-runs', idempotent: true },
AGENT_RUN: { method: 'GET', path: '/api/v1/agent-runs/{runId}' },
});
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 || '';
this.payload = options.payload || null;
}
}
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)),
cache: 'no-store',
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;
const hasBusinessError = Object.prototype.hasOwnProperty.call(payload, 'code') && payload.code !== 0;
if (!response.ok || payload.error || hasBusinessError) {
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
|| (hasBusinessError ? payload.message : '')
|| validationDetail
|| (response.status ? `请求失败(HTTP ${response.status})` : '请求未完成');
const businessStatus = hasBusinessError && Number(payload.code) >= 400
? Number(payload.code)
: response.status;
const error = new ApiError(message, {
code: detail.code || (hasBusinessError ? `BUSINESS_${payload.code}` : undefined),
status: businessStatus,
retryable: detail.retryable,
fieldErrors: detail.field_errors,
traceId: responseTraceId,
payload,
});
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);
}
async function requestFile(endpointId, options = {}) {
const endpoint = ENDPOINTS[endpointId];
if (!endpoint) throw new ApiError(`未注册端点 ${endpointId}`, { code: 'ENDPOINT_NOT_REGISTERED' });
const traceId = crypto.randomUUID();
const headers = { Accept: '*/*', 'X-Trace-ID': traceId, ...(options.headers || {}) };
const token = getAccessToken();
if (endpoint.auth !== false && token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${pathFor(endpoint, options.pathParams)}${new URLSearchParams(options.query || {}).toString() ? `?${new URLSearchParams(options.query).toString()}` : ''}`, {
method: endpoint.method,
headers,
cache: 'no-store',
signal: options.signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const detail = payload.error || {};
if (response.status === 401 && endpoint.auth !== false) clearAuthSession();
throw new ApiError(detail.message || `文件请求失败(HTTP ${response.status})`, {
code: detail.code,
status: response.status,
traceId: payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId,
});
}
return {
blob: await response.blob(),
filename: response.headers.get('Content-Disposition') || '',
contentType: response.headers.get('Content-Type') || '',
traceId: response.headers.get('X-Trace-ID') || traceId,
};
}
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 }); },
file(endpointId, options = {}) { return requestFile(endpointId, options); },
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 };