feat(portal): complete advisor workspace operations

This commit is contained in:
张胜宇
2026-09-13 23:04:35 +08:00
parent 4629e7d5dd
commit 3325232e2a
19 changed files with 132 additions and 18 deletions
+5 -1
View File
@@ -1,4 +1,4 @@
import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js';
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 },
@@ -54,6 +54,10 @@ const ENDPOINTS = Object.freeze({
ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' },
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' },
OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' },
OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' },
});
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { requireCustomer } from '/static/portal/common/auth.js';
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderError, renderLoading } from '/static/portal/common/state-view.js';
@@ -1,5 +1,5 @@
import { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913';
import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js';
import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml } from '/static/portal/common/formatters.js';
const VISITOR_TOKEN_KEY = 'portalVisitorToken';
+1 -1
View File
@@ -3,7 +3,7 @@ import {
getAuthContext,
startAuthSync,
switchAccount,
} from '/static/portal/common/auth.js';
} from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml } from '/static/portal/common/formatters.js';
import { mountCustomerServiceWidget } from '/static/portal/common/customer-service-widget/widget.js';
+1 -1
View File
@@ -4,7 +4,7 @@ import {
setAuthSession,
staffHomeForRoles,
startAuthSync,
} from '/static/portal/common/auth.js';
} from '/static/portal/common/auth.js?v=20260913';
const REASON_MESSAGES = Object.freeze({
'account-switched': '当前浏览器中的账号已切换,请重新登录后继续。',
+1 -1
View File
@@ -1,4 +1,4 @@
import { getPermissions } from '/static/portal/common/auth.js';
import { getPermissions } from '/static/portal/common/auth.js?v=20260913';
export function applyPermissionGuard(root = document) {
const permissions = new Set(getPermissions());
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { requireCustomer } from '/static/portal/common/auth.js';
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { applyPermissionGuard } from '/static/portal/common/permission-guard.js';
import { renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260912';
@@ -50,6 +50,12 @@ if (requireCustomer()) {
const productCode = new URLSearchParams(window.location.search).get('product_code');
const target = productCode ? document.querySelector(`[data-product-code="${CSS.escape(productCode)}"]`) : null;
if (target) { target.classList.add('dashboard-holdings__highlight'); target.scrollIntoView({ block: 'center' }); window.setTimeout(() => target.classList.remove('dashboard-holdings__highlight'), 1500); }
if (new URLSearchParams(window.location.search).get('action') === 'trade') {
const orderForm = document.querySelector('[data-order-form]');
const productInput = orderForm?.querySelector('[name="product_code"]');
if (productInput && productCode) productInput.value = productCode;
if (dialog?.showModal && productCode && !dialog.open) dialog.showModal();
}
}
async function load({ force = false } = {}) {
if (!force && state.data && Date.now() - state.fetchedAt < 60000) { renderData(state.data, true); return; }
@@ -19,6 +19,6 @@
<section data-quick-actions></section>
<dialog class="order-dialog" data-order-dialog><form class="order-dialog__form" data-order-form><div class="order-dialog__header"><h2>提交模拟委托</h2><button class="button button--quiet icon-button" type="button" aria-label="关闭" data-order-close>关闭</button></div><p class="order-dialog__notice">首版仅支持市价委托,系统会立即全额成交。请确认基金代码与数量。</p><div class="form-alert" role="alert" data-order-alert></div><div class="form-field"><label class="form-field__label" for="product-code">基金代码</label><input class="form-field__input" id="product-code" name="product_code" required maxlength="32"></div><div class="form-field"><label class="form-field__label" for="order-side">交易方向</label><select class="form-field__input" id="order-side" name="order_side"><option value="buy">买入</option><option value="sell">卖出</option></select></div><div class="form-field"><label class="form-field__label" for="quantity">份额</label><input class="form-field__input" id="quantity" name="quantity" type="number" min="0.0001" step="0.0001" required></div><button class="button button--primary" type="submit">确认提交</button></form></dialog>
</main>
<script type="module" src="/static/portal/customer/dashboard/dashboard.js?v=20260912-2"></script>
<script type="module" src="/static/portal/customer/dashboard/dashboard.js?v=20260913-3"></script>
</body>
</html>
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { requireCustomerOnly } from '/static/portal/common/auth.js';
import { requireCustomerOnly } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { showToast } from '/static/portal/common/notifications.js';
@@ -1,11 +1,29 @@
.advisor-shell { --brand: #2d6f67; --brand-dark: #20574f; --brand-soft: #e1f0eb; --canvas: #f3f7f5; }
.advisor-hero { background-image: linear-gradient(118deg, rgba(17, 50, 55, .96), rgba(39, 99, 88, .82) 64%, rgba(67, 126, 108, .68)), url('/static/portal/guest/home/assets/wealth_architecture_hero.jpg'); }
.advisor-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: var(--space-4); }
.advisor-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--space-4); align-items: start; }
.advisor-panel { min-height: 300px; }
.advisor-panel--accent { background: linear-gradient(145deg, var(--surface), var(--surface-soft)); }
.advisor-actions { min-height: 0; }
.action-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.action-card { min-height: 112px; padding: var(--space-4); display: grid; align-content: center; gap: var(--space-2); color: var(--ink); text-align: left; background: linear-gradient(145deg, var(--surface), var(--surface-soft)); border: 1px solid var(--line); border-radius: var(--radius-md); cursor: pointer; transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; }
.action-card:hover, .action-card:focus-visible { border-color: var(--brand); box-shadow: 0 10px 24px rgba(34, 91, 79, .11); transform: translateY(-2px); outline: 0; }
.action-card strong { font-size: 15px; font-weight: 700; }
.action-card span { color: var(--muted); font-size: var(--fs-small); line-height: 1.55; }
.advisor-output { margin-top: var(--space-4); min-height: 148px; padding: var(--space-4); background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-md); }
.advisor-output__placeholder, .advisor-output__loading { display: grid; min-height: 110px; place-items: center; color: var(--muted); text-align: center; }
.advisor-output__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
.advisor-output__message { margin: 0; color: var(--ink-soft); line-height: 1.7; }
.advisor-inline-form { display: flex; align-items: end; gap: var(--space-3); }
.advisor-inline-form .form-field { flex: 1; }
.advisor-form { display: grid; gap: var(--space-4); }
.advisor-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.advisor-form__wide { grid-column: 1 / -1; }
.advisor-form__textarea { height: auto; padding-top: var(--space-3); padding-bottom: var(--space-3); resize: vertical; }
.advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); }
.advisor-card:last-child { border-bottom: 0; }
.advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; }
.advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
.advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
@media (max-width: 760px) { .advisor-grid { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .advisor-workspace { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .advisor-grid { grid-template-columns: 1fr; } .action-grid, .advisor-form__grid { grid-template-columns: 1fr; } .advisor-form__wide { grid-column: auto; } .advisor-inline-form { align-items: stretch; flex-direction: column; } }
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { getAuthContext, requireAdvisor } from '/static/portal/common/auth.js';
import { getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
@@ -16,6 +16,8 @@ if (requireAdvisor()) {
const context = getAuthContext();
const list = document.querySelector('[data-recommendations]');
const metrics = document.querySelector('[data-advisor-metrics]');
const output = document.querySelector('[data-action-output]');
const alert = document.querySelector('[data-action-alert]');
document.querySelector('[data-advisor-name]').textContent = context?.username || '投顾人员';
document.querySelector('[data-advisor-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
async function load() {
@@ -28,6 +30,53 @@ if (requireAdvisor()) {
list.innerHTML = rows.map((row) => `<article class="advisor-card"><h3 class="advisor-card__title">${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}</h3><p class="advisor-card__meta">发布时间:${escapeHtml(formatDateTime(row.published_at))}</p><div class="advisor-card__content">${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}</div></article>`).join('');
} catch (error) { apiClient.reportError(error); renderError(list, error, load); }
}
const actionLabels = { portfolio: '组合分析', allocation: '资产配置', recommend: '生成推荐草案', goal: '录入客户目标' };
const resultMessages = {
profile_required: '客户尚未完成风险测评,请先完成测评后再分析。',
investment_goal_required: '暂无已确认投资目标,暂不能生成配置或推荐。',
investment_goal_invalid: '投资目标数据不完整,请检查客户目标。',
no_positions: '当前客户暂无可分析的持仓。',
valuation_required: '持仓缺少可用市值,暂不能计算集中度。',
};
function showAlert(message, kind = 'error') {
alert.textContent = message;
alert.className = `form-alert form-alert--visible${kind === 'info' ? ' form-alert--info' : ''}`;
}
function clearAlert() { alert.textContent = ''; alert.className = 'form-alert'; }
function renderResult(title, data) {
const status = data?.status;
const message = resultMessages[status];
output.innerHTML = `<div class="advisor-output__header"><strong>${escapeHtml(title)}</strong><span class="status-tag status-tag--active">${escapeHtml(status || 'ready')}</span></div>${message ? `<p class="advisor-output__message">${escapeHtml(message)}</p>` : `<pre class="json-view">${escapeHtml(JSON.stringify(data, null, 2))}</pre>`}`;
}
function renderGoalForm() {
output.innerHTML = `<form class="advisor-form" data-goal-form><div class="advisor-form__grid"><label class="form-field"><span class="form-field__label">客户 ID(可选)</span><input class="form-field__input" name="customer_id" type="number" min="1" placeholder="留空表示当前账号"></label><label class="form-field"><span class="form-field__label">基准名称</span><input class="form-field__input" name="benchmark_name" required maxlength="128" value="中证全债指数"></label><label class="form-field"><span class="form-field__label">年化收益下限(%)</span><input class="form-field__input" name="annualized_return_lower_pct" type="number" min="0" max="100" step="0.01" required value="3"></label><label class="form-field"><span class="form-field__label">年化收益上限(%)</span><input class="form-field__input" name="annualized_return_upper_pct" type="number" min="0" max="100" step="0.01" required value="6"></label><label class="form-field"><span class="form-field__label">最大回撤(%)</span><input class="form-field__input" name="max_drawdown_pct" type="number" min="0" max="100" step="0.01" required value="10"></label><label class="form-field"><span class="form-field__label">投资期限(月)</span><input class="form-field__input" name="investment_horizon_months" type="number" min="1" max="600" required value="36"></label><label class="form-field"><span class="form-field__label">流动性要求</span><select class="form-field__input" name="liquidity_requirement"><option value="daily">可随时使用</option><option value="within_7_days">7 日内可使用</option><option value="within_30_days">30 日内可使用</option><option value="over_30_days">30 日后可使用</option></select></label><label class="form-field advisor-form__wide"><span class="form-field__label">备注</span><textarea class="form-field__input advisor-form__textarea" name="notes" maxlength="1000" rows="3" placeholder="记录客户目标背景(不得填写收益承诺)"></textarea></label></div><button class="button button--primary" type="submit">保存客户目标</button></form>`;
output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal);
}
async function submitGoal(event) {
event.preventDefault(); clearAlert();
const form = event.currentTarget; const value = (name) => form.elements[name].value.trim();
const lower = Number(value('annualized_return_lower_pct')); const upper = Number(value('annualized_return_upper_pct'));
if (lower > upper) { showAlert('年化收益下限不能高于上限。'); return; }
const body = { annualized_return_lower_pct: value('annualized_return_lower_pct'), annualized_return_upper_pct: value('annualized_return_upper_pct'), max_drawdown_pct: value('max_drawdown_pct'), liquidity_requirement: value('liquidity_requirement'), investment_horizon_months: Number(value('investment_horizon_months')), benchmark_name: value('benchmark_name'), notes: value('notes') || null };
if (value('customer_id')) body.customer_id = Number(value('customer_id'));
await runAction('ADVISOR_CREATE_GOAL', body, '客户目标已提交,等待确认与审核');
}
async function runAction(endpoint, body, title) {
clearAlert(); output.innerHTML = '<div class="advisor-output__loading">正在请求服务端分析…</div>';
try { const response = await apiClient.post(endpoint, body); const data = response.data ?? response; renderResult(title, data); }
catch (error) { apiClient.reportError(error); showAlert(error.message || '请求未完成,请稍后重试。'); output.innerHTML = '<div class="advisor-output__placeholder">请求失败,请检查权限或稍后重试。</div>'; }
}
function openAction(action) {
clearAlert();
if (action === 'goal') { renderGoalForm(); return; }
if (action === 'recommend') {
output.innerHTML = '<div class="advisor-inline-form"><label class="form-field"><span class="form-field__label">推荐数量</span><select class="form-field__input" data-recommend-limit><option value="1">1 个</option><option value="2">2 个</option><option value="3" selected>3 个</option></select></label><button class="button button--primary" type="button" data-submit-recommend>生成草案</button></div>';
output.querySelector('[data-submit-recommend]').addEventListener('click', () => runAction('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案'));
return;
}
runAction(action === 'portfolio' ? 'ADVISOR_ANALYSIS' : 'ADVISOR_ALLOCATION', {}, actionLabels[action]);
}
document.querySelectorAll('[data-action]').forEach((button) => button.addEventListener('click', () => openAction(button.dataset.action)));
document.querySelector('[data-refresh]').addEventListener('click', load);
load();
}
@@ -1,3 +1,3 @@
<!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/operations.css"><link rel="stylesheet" href="/static/portal/employee-advisor/dashboard/dashboard.css?v=20260913"></head>
<body><main id="main-content" class="page-shell operations-shell advisor-shell"><section class="operations-hero advisor-hero fade-in"><div class="operations-hero__content"><p class="operations-kicker">客户洞察与方案协作</p><h1 class="operations-hero__title">投顾工作台</h1><p class="operations-hero__copy">围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。</p><div class="operations-hero__signals"><span>客户目标</span><span>组合分析</span><span>方案发布</span></div></div><div class="operations-hero__meta"><strong data-advisor-name>投顾人员</strong><span data-advisor-scope>权限加载中</span></div></section><section class="metric-grid" data-advisor-metrics aria-label="投顾概览"></section><section class="advisor-grid"><article class="panel panel--flush advisor-panel"><div class="panel__header"><div><h2 class="panel__title">已发布方案</h2><p class="section-heading__meta">服务端已审核并发布的客户方案</p></div><button class="button table-action" type="button" data-refresh>刷新</button></div><div class="panel__body" data-recommendations></div></article><article class="panel panel--flush advisor-panel advisor-panel--accent"><div class="panel__header"><div><h2 class="panel__title">工作边界</h2><p class="section-heading__meta">建议生成与审核留痕</p></div></div><div class="panel__body"><ul class="operations-list"><li class="operations-list__item"><strong>先读画像与目标</strong><span>仅访问分配范围内客户,不展示未授权信息。</span></li><li class="operations-list__item"><strong>再做组合分析</strong><span>分析结果是工作草案,不代替人工判断。</span></li><li class="operations-list__item"><strong>最后提交审核</strong><span>发布前保留审核、版本与审计记录。</span></li></ul></div></article></section></main><script type="module" src="/static/portal/employee-advisor/dashboard/dashboard.js"></script></body></html>
<body><main id="main-content" class="page-shell operations-shell advisor-shell"><section class="operations-hero advisor-hero fade-in"><div class="operations-hero__content"><p class="operations-kicker">客户洞察与方案协作</p><h1 class="operations-hero__title">投顾工作台</h1><p class="operations-hero__copy">围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。</p><div class="operations-hero__signals"><span>客户目标</span><span>组合分析</span><span>方案发布</span></div></div><div class="operations-hero__meta"><strong data-advisor-name>投顾人员</strong><span data-advisor-scope>权限加载中</span></div></section><section class="metric-grid" data-advisor-metrics aria-label="投顾概览"></section><section class="advisor-workspace"><article class="panel panel--flush advisor-panel advisor-actions"><div class="panel__header"><div><h2 class="panel__title">工作操作</h2><p class="section-heading__meta">调用已授权的投顾分析能力,结果仅作为方案草案</p></div></div><div class="panel__body"><div class="action-grid"><button class="action-card" type="button" data-action="portfolio"><strong>组合分析</strong><span>查看持仓集中度、行业暴露与风险提示</span></button><button class="action-card" type="button" data-action="allocation"><strong>资产配置</strong><span>按已确认目标生成约束下的配置建议</span></button><button class="action-card" type="button" data-action="recommend"><strong>生成推荐草案</strong><span>基于适当性与流动性筛选场内基金</span></button><button class="action-card" type="button" data-action="goal"><strong>录入客户目标</strong><span>记录收益区间、回撤边界与投资期限</span></button></div><div class="form-alert" data-action-alert role="status"></div><div class="advisor-output" data-action-output aria-live="polite"><div class="advisor-output__placeholder">选择一项操作,结果将在这里展示。</div></div></div></article><article class="panel panel--flush advisor-panel"><div class="panel__header"><div><h2 class="panel__title">已发布方案</h2><p class="section-heading__meta">服务端已审核并发布的客户方案</p></div><button class="button table-action" type="button" data-refresh>刷新</button></div><div class="panel__body" data-recommendations></div></article></section><section class="advisor-grid"><article class="panel panel--flush advisor-panel advisor-panel--accent"><div class="panel__header"><div><h2 class="panel__title">工作边界</h2><p class="section-heading__meta">建议生成与审核留痕</p></div></div><div class="panel__body"><ul class="operations-list"><li class="operations-list__item"><strong>先读画像与目标</strong><span>仅访问分配范围内客户,不展示未授权信息。</span></li><li class="operations-list__item"><strong>再做组合分析</strong><span>分析结果是工作草案,不代替人工判断。</span></li><li class="operations-list__item"><strong>最后提交审核</strong><span>发布前保留审核、版本与审计记录。</span></li></ul></div></article></section></main><script type="module" src="/static/portal/employee-advisor/dashboard/dashboard.js"></script></body></html>
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js';
import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { showToast } from '/static/portal/common/notifications.js';
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js';
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
@@ -1,5 +1,5 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913-2';
import { getAuthContext, getPermissions, requireRiskStaff, updateAuthPermissions } from '/static/portal/common/auth.js';
import { getAuthContext, getPermissions, requireRiskStaff, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { showToast } from '/static/portal/common/notifications.js';
+1 -1
View File
@@ -1,7 +1,7 @@
import { formatPercent, escapeHtml } from '/static/portal/common/formatters.js';
import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE } from '/static/portal/common/mock-data.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { getAuthContext, staffHomeForRoles } from '/static/portal/common/auth.js';
import { getAuthContext, staffHomeForRoles } from '/static/portal/common/auth.js?v=20260913';
mountShell({ active: 'home' });
document.documentElement.classList.add('motion-ready');
@@ -9,7 +9,7 @@
</head>
<body>
<main id="main-content" class="page-shell" data-detail-root>
<div class="page-heading"><div><a class="detail-back" href="/portal/guest/products/">返回基金列表</a><h1 class="page-heading__title" data-product-name>基金详情</h1><p class="page-heading__description" data-product-meta></p></div><div class="page-heading__actions"><a class="button" href="/portal/customer/login/">登录后交易</a></div></div>
<div class="page-heading"><div><a class="detail-back" href="/portal/guest/products/">返回基金列表</a><h1 class="page-heading__title" data-product-name>基金详情</h1><p class="page-heading__description" data-product-meta></p></div><div class="page-heading__actions"><a class="button" data-trade-action href="/portal/customer/login/">登录后交易</a></div></div>
<div class="source-notice" role="note" data-source-notice></div>
<div class="product-note" role="note" data-product-note hidden></div>
<section class="detail-grid">
@@ -18,6 +18,6 @@
</section>
<section class="panel risk-disclosure"><div class="panel__header"><h2 class="panel__title">风险与交易说明</h2></div><div class="panel__body"><div class="risk-disclosure__grid"><div><strong>风险等级</strong><span data-risk-level></span></div><div><strong>最小金额</strong><span data-min-amount></span></div><div><strong>交易方式</strong><span>场内市价模拟成交</span></div></div><p>基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。</p></div></section>
</main>
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-5"></script>
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-6"></script>
</body>
</html>
@@ -1,5 +1,6 @@
import { escapeHtml, formatCurrency, formatPercent } from '/static/portal/common/formatters.js';
import { getMockNavHistory, MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js';
import { getAuthContext } from '/static/portal/common/auth.js?v=20260913';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
mountShell({ active: 'products' });
@@ -8,6 +9,13 @@ const product = MOCK_PRODUCTS.find((item) => item.product_code === code) || MOCK
const history = getMockNavHistory(product.id);
const change = MOCK_RANKING_CHANGE[product.product_code];
const tradeAction = document.querySelector('[data-trade-action]');
const authContext = getAuthContext();
if (tradeAction && authContext?.roles?.includes('customer')) {
tradeAction.textContent = '进入交易';
tradeAction.href = `/portal/customer/dashboard/?product_code=${encodeURIComponent(product.product_code)}&action=trade`;
}
document.title = `${product.product_name} · 南方财富`;
document.querySelector('[data-product-name]').textContent = product.product_name;
document.querySelector('[data-product-meta]').textContent = `${product.product_code} · ${product.exchange_code} · ${product.product_category}`;
+29
View File
@@ -75,6 +75,21 @@ def test_api_client_registers_all_trading_endpoint_ids() -> None:
assert f"{endpoint_id}:" in source
def test_product_detail_preserves_customer_session_for_trade_entry() -> None:
html = (PORTAL / "guest" / "product-detail" / "index.html").read_text(encoding="utf-8")
source = (PORTAL / "guest" / "product-detail" / "product-detail.js").read_text(
encoding="utf-8"
)
dashboard = (PORTAL / "customer" / "dashboard" / "dashboard.js").read_text(
encoding="utf-8"
)
assert 'data-trade-action' in html
assert "getAuthContext" in source
assert "textContent = '进入交易'" in source
assert "action=trade" in source
assert "productInput.value = productCode" in dashboard
def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
for endpoint_id in (
@@ -86,6 +101,20 @@ def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None:
assert f"{endpoint_id}:" in source
def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
dashboard = (PORTAL / "employee-advisor" / "dashboard" / "index.html").read_text(
encoding="utf-8"
)
for endpoint_id in (
"ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
):
assert f"{endpoint_id}:" in source
for label in ("组合分析", "资产配置", "生成推荐草案", "录入客户目标"):
assert label in dashboard
def test_risk_scan_endpoint_uses_extended_timeout() -> None:
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
assert (