Files
group_fqcd_jr/app/static/portal/common/customer-service-widget/widget.js
T
lzf_0626 9cb0f6e474 feat(portal): 公开产品接口(P001)落地,访客三页改读真实数据
访客首页推荐、产品列表、产品详情此前读的是前端手写的
`app/static/portal/common/mock-data.js`:只有 8 只,且**其中 6 只根本不在
`fin_product` 里**(159915 / 512100 / 513100 / 511360 / 159645 / 159925),
还把海富通的 `511360` 标成"南方短融ETF"、把 `510500` 净值写成 6.742(真实 7.6027)。
`README.md` 把它记为"公开产品 HTTP 接口尚未实现"的临时方案。

## 接口

新增 `GET /api/v1/products`(编号 **P001**,已在 `docs/05` §19 总目录与 §19 说明中登记):

- **要求有效令牌但不校验权限码**:访客令牌的角色是 `visitor`、不带任何权限,
  这与 `/api/v1/agent-runs`、`/api/v1/conversations` 面向访客的口径一致;
  产品信息本身是公开信息。数据面只暴露 `fin_product`(`status='上市'`)与
  `fin_market_price` 的最新一行,**不含任何账户/客户字段**(由 integration 测试守着)。
- 字段与 `mock-data.js` 对齐,因此前端渲染与筛选逻辑**一行未改**。
- `change_pct` **可能是 `null`**:当日涨跌需要两个交易日的收盘价,行情只同步过一天时
  算不出来。前端对 `null` 显示"暂无" —— `formatPercent(null)` 会渲染成 `+0.00%`,
  那等于对客户说"今天平盘",是编出来的结论。

## 前端

- 新增 `common/visitor-token.js`:访客令牌的**唯一实现**(这段逻辑原先只写在客服浮窗里,
  现在四处要用;复制四份的话存储 key 与过期判断迟早不一致);`widget.js` 改为复用它。
- 新增 `common/product-notes.js`:产品级披露文案。510300"非本公司发行"那条是**合规披露**,
  不能随 mock 一起删掉。
- 三个访客页改读接口,并显式区分 loading / error 状态。
- **删除 `common/mock-data.js`**。
- 产品详情页**不再画走势图**:`fin_nav_history` 目前 0 行,此前那条曲线是 mock 里
  12 个编造点位 —— 走势图最容易被当成真数据,宁可不画,并显式说明"尚未接入"。

## 顺带修正

`min_amount` 在库里全是 0.00(那本是场外"最小认购金额"的概念,对场内按手交易的 ETF
不适用),页面不再显示"¥0.00"(会被读成零元起购),改为"1 手(100 份)起"。

验证:接口实测 `count=20`;ruff 通过;mypy 251 文件 0 错;unit+contract 1387 passed;
integration 106 passed;e2e 冒烟 40/40。
2026-09-13 22:57:48 +08:00

147 lines
8.5 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 { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913';
import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js';
import { escapeHtml } from '/static/portal/common/formatters.js';
import { saveVisitorToken, visitorToken, visitorTokenExpired } from '/static/portal/common/visitor-token.js';
function mountMarkup(mode) {
const isCustomer = mode === 'customer';
document.body.insertAdjacentHTML('beforeend', `
<aside class="cs-widget" data-cs-widget data-mode="${isCustomer ? 'customer' : 'visitor'}">
<button class="cs-widget__launcher" type="button" aria-label="打开智能客服" aria-expanded="false" data-cs-launcher>
<span class="cs-widget__orb" aria-hidden="true"><span></span></span>
<span class="cs-widget__launcher-copy"><strong>南方小助</strong><small>${isCustomer ? '专属服务' : '基金问答'}</small></span>
</button>
<section class="cs-widget__panel" aria-label="智能客服对话" hidden data-cs-panel>
<header class="cs-widget__header"><div><p class="cs-widget__eyebrow">${isCustomer ? '已登录客户' : '公开服务'}</p><h2>智能客服</h2></div><button class="cs-widget__close" type="button" aria-label="关闭客服" data-cs-close>×</button></header>
<div class="cs-widget__notice">${isCustomer ? '可查询您的测评、账户与基金服务信息。' : '可查询公开基金与服务规则,账户问题请先登录。'}</div>
<div class="cs-widget__messages" role="log" aria-live="polite" data-cs-messages><div class="cs-widget__message cs-widget__message--assistant">您好,我是南方小助。${isCustomer ? '可以帮您查询账户相关服务。' : '可以帮您了解公开基金与服务规则。'}</div></div>
<form class="cs-widget__composer" data-cs-form><input name="message" maxlength="8000" autocomplete="off" placeholder="请输入您的问题" aria-label="输入问题" required><button type="submit" aria-label="发送问题">发送</button></form>
<div class="cs-widget__footer"><button type="button" data-cs-login>${isCustomer ? '转人工客服' : '登录后查询账户'}</button><span data-cs-status>内容来自已发布知识与权限范围</span></div>
</section>
</aside>`);
}
export function mountCustomerServiceWidget(mode = 'public') {
if (document.querySelector('[data-cs-widget]')) return;
mountMarkup(mode);
const root = document.querySelector('[data-cs-widget]');
const launcher = root.querySelector('[data-cs-launcher]');
const panel = root.querySelector('[data-cs-panel]');
const messages = root.querySelector('[data-cs-messages]');
const form = root.querySelector('[data-cs-form]');
const input = form.elements.message;
const status = root.querySelector('[data-cs-status]');
const isCustomer = mode === 'customer';
let sessionId = '';
let busy = false;
const setOpen = (open) => {
panel.hidden = !open;
launcher.setAttribute('aria-expanded', String(open));
root.classList.toggle('cs-widget--open', open);
if (open) window.setTimeout(() => input.focus(), 60);
};
const addMessage = (text, role = 'assistant') => {
const item = document.createElement('div');
item.className = `cs-widget__message cs-widget__message--${role}`;
item.textContent = text;
messages.appendChild(item);
messages.scrollTop = messages.scrollHeight;
return item;
};
const authHeaders = () => {
const token = getAccessToken();
return token ? {} : { Authorization: `Bearer ${visitorToken()}` };
};
async function ensureVisitorToken() {
if (isCustomer) return '';
let token = visitorToken();
if (!token || visitorTokenExpired(token)) {
const response = await apiClient.post('V001');
token = response.data?.access_token || '';
if (token) saveVisitorToken(token);
}
return token;
}
async function ensureSession() {
if (sessionId) return sessionId;
// Visitor tokens intentionally carry only agent:run + knowledge:query. They
// use an ephemeral run session, while authenticated customers get a persisted
// conversation resource with conversation:create permission.
if (!isCustomer) {
await ensureVisitorToken();
sessionId = crypto.randomUUID();
return sessionId;
}
await ensureVisitorToken();
const response = await apiClient.post('C001', { agent_type: 'customer_service' }, { headers: authHeaders() });
sessionId = response.data?.session_id || '';
if (!sessionId) throw new ApiError('客服会话未建立');
return sessionId;
}
async function waitForRun(runId) {
// 实测(本机 + DeepSeek):整条链路「受理 0.05s + 意图分类 + 知识检索 + 落库」
// 约 4.1–4.8 秒。原参数是「350ms 后首次、之后每 700ms 一次、共 14 次」≈ 9.5 秒封顶,
// 后端稍一抖动就撞上限;而且平均要多等半个轮询周期(350ms)才看到结果。
// 改为「300ms 后首次、之后每 500ms 一次、共 40 次」≈ 20 秒:感知延迟压到半秒内,
// 同时给模型/检索抖动留出余量。
// ⚠️ 若这里仍然超时,先确认 Agent Worker 已启动(`python -m app.worker`)——
// 没有 Worker 时 run 会一直停在 queued,任何轮询预算都不够。
for (let attempt = 0; attempt < 40; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, attempt ? 500 : 300));
const response = await apiClient.get('R002', { pathParams: { runId }, headers: authHeaders() });
const snapshot = response.data || {};
if (snapshot.status === 'succeeded') return snapshot.result?.reply || snapshot.result?.content || '暂时没有可展示的回复。';
if (snapshot.status === 'failed' || snapshot.status === 'cancelled') throw new ApiError('客服暂时无法完成回答,请稍后重试。');
}
throw new ApiError('客服繁忙,暂时没能给出答复,请稍后重试。');
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const message = String(input.value || '').trim();
if (!message || busy) return;
busy = true; input.value = ''; input.disabled = true;
addMessage(message, 'user');
const pending = addMessage('正在查询已发布资料…', 'assistant');
status.textContent = '正在安全处理';
try {
const currentSession = await ensureSession();
const accepted = await apiClient.post('R001', {
agent_type: 'customer_service', session_id: currentSession, message,
idempotency_key: crypto.randomUUID().replaceAll('-', ''),
}, { headers: authHeaders() });
pending.textContent = await waitForRun(accepted.data?.run_id);
status.textContent = '已完成回答';
} catch (error) {
pending.remove();
if (error.status === 401 || error.code === 'AUTHENTICATION_REQUIRED') {
addMessage(isCustomer ? '登录状态已过期,请重新登录后继续。' : '访客服务已过期,请刷新页面后重试。');
} else if (error.code === 'AGENT_PERMISSION_DENIED') {
addMessage('当前问题超出该入口的服务范围,建议转人工客服。');
} else {
addMessage(error.message || '请求未完成,请稍后重试。');
}
status.textContent = '请求未完成';
apiClient.reportError(error);
} finally { busy = false; input.disabled = false; input.focus(); }
});
launcher.addEventListener('click', () => setOpen(panel.hidden));
root.querySelector('[data-cs-close]').addEventListener('click', () => setOpen(false));
root.querySelector('[data-cs-login]').addEventListener('click', async () => {
if (!isCustomer) { window.location.assign(`/portal/customer/login/?next=${encodeURIComponent(window.location.pathname)}`); return; }
if (!sessionId) { addMessage('您可以直接输入问题,我会先查询已发布资料。'); input.focus(); return; }
try {
const response = await apiClient.post('C005', { reason_code: 'user_requested', reason_detail: '客户通过智能客服浮窗申请人工协助' }, { pathParams: { sessionId } });
addMessage(`已记录转人工请求(${response.data?.handover_id || '处理中'}),请稍候由客服人员接入。`);
} catch (error) {
addMessage(error.message || '转人工请求未完成,请稍后重试。');
apiClient.reportError(error);
}
});
// Keep the widget identity aligned with an account switch without exposing account data.
window.addEventListener('portal:auth-changed', () => {
const context = getAuthContext();
if (!context && isCustomer) window.location.reload();
});
}