From 3325232e2a542112321799f6e1fd58840e7897c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=83=9C=E5=AE=87?= <17412268+zzzzz11122222@user.noreply.gitee.com> Date: Sun, 13 Sep 2026 23:04:35 +0800 Subject: [PATCH] feat(portal): complete advisor workspace operations --- app/static/portal/common/api-client.js | 6 ++- .../portal/common/customer-list-page.js | 2 +- .../common/customer-service-widget/widget.js | 2 +- app/static/portal/common/layout/app-shell.js | 2 +- app/static/portal/common/login-controller.js | 2 +- app/static/portal/common/permission-guard.js | 2 +- .../portal/customer/dashboard/dashboard.js | 8 ++- .../portal/customer/dashboard/index.html | 2 +- .../risk-questionnaire/risk-questionnaire.js | 2 +- .../employee-advisor/dashboard/dashboard.css | 20 +++++++- .../employee-advisor/dashboard/dashboard.js | 51 ++++++++++++++++++- .../employee-advisor/dashboard/index.html | 2 +- .../employee-console/workspace/workspace.js | 2 +- .../dashboard/dashboard.js | 2 +- .../employee-risk/dashboard/dashboard.js | 2 +- app/static/portal/guest/home/home.js | 2 +- .../portal/guest/product-detail/index.html | 4 +- .../guest/product-detail/product-detail.js | 8 +++ tests/unit/api/test_portal_frontend.py | 29 +++++++++++ 19 files changed, 132 insertions(+), 18 deletions(-) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 0b4937f..4994675 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -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' }, }); diff --git a/app/static/portal/common/customer-list-page.js b/app/static/portal/common/customer-list-page.js index 0d956c0..9b7c4a5 100644 --- a/app/static/portal/common/customer-list-page.js +++ b/app/static/portal/common/customer-list-page.js @@ -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'; diff --git a/app/static/portal/common/customer-service-widget/widget.js b/app/static/portal/common/customer-service-widget/widget.js index 386d4db..34937a1 100644 --- a/app/static/portal/common/customer-service-widget/widget.js +++ b/app/static/portal/common/customer-service-widget/widget.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'; diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js index 3ad8897..af98dd0 100644 --- a/app/static/portal/common/layout/app-shell.js +++ b/app/static/portal/common/layout/app-shell.js @@ -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'; diff --git a/app/static/portal/common/login-controller.js b/app/static/portal/common/login-controller.js index e0d935f..9e2de6e 100644 --- a/app/static/portal/common/login-controller.js +++ b/app/static/portal/common/login-controller.js @@ -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': '当前浏览器中的账号已切换,请重新登录后继续。', diff --git a/app/static/portal/common/permission-guard.js b/app/static/portal/common/permission-guard.js index 3dba9ec..1a5055c 100644 --- a/app/static/portal/common/permission-guard.js +++ b/app/static/portal/common/permission-guard.js @@ -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()); diff --git a/app/static/portal/customer/dashboard/dashboard.js b/app/static/portal/customer/dashboard/dashboard.js index 80ec15d..503e13f 100644 --- a/app/static/portal/customer/dashboard/dashboard.js +++ b/app/static/portal/customer/dashboard/dashboard.js @@ -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; } diff --git a/app/static/portal/customer/dashboard/index.html b/app/static/portal/customer/dashboard/index.html index 91da390..3f5950f 100644 --- a/app/static/portal/customer/dashboard/index.html +++ b/app/static/portal/customer/dashboard/index.html @@ -19,6 +19,6 @@

提交模拟委托

首版仅支持市价委托,系统会立即全额成交。请确认基金代码与数量。

- + diff --git a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js index 15c6d39..0f63a7f 100644 --- a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js +++ b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js @@ -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'; diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.css b/app/static/portal/employee-advisor/dashboard/dashboard.css index 4781765..675cc18 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.css +++ b/app/static/portal/employee-advisor/dashboard/dashboard.css @@ -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; } } diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js index e938323..6ec2019 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.js +++ b/app/static/portal/employee-advisor/dashboard/dashboard.js @@ -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) => `

${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}

发布时间:${escapeHtml(formatDateTime(row.published_at))}

${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}
`).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 = `
${escapeHtml(title)}${escapeHtml(status || 'ready')}
${message ? `

${escapeHtml(message)}

` : `
${escapeHtml(JSON.stringify(data, null, 2))}
`}`; + } + function renderGoalForm() { + output.innerHTML = `
`; + 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 = '
正在请求服务端分析…
'; + 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 = '
请求失败,请检查权限或稍后重试。
'; } + } + function openAction(action) { + clearAlert(); + if (action === 'goal') { renderGoalForm(); return; } + if (action === 'recommend') { + output.innerHTML = '
'; + 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(); } diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html index 18920e7..77b615d 100644 --- a/app/static/portal/employee-advisor/dashboard/index.html +++ b/app/static/portal/employee-advisor/dashboard/index.html @@ -1,3 +1,3 @@ 投顾工作台 · 南方财富 -

客户洞察与方案协作

投顾工作台

围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。

客户目标组合分析方案发布
投顾人员权限加载中

已发布方案

服务端已审核并发布的客户方案

工作边界

建议生成与审核留痕

  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
+

客户洞察与方案协作

投顾工作台

围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。

客户目标组合分析方案发布
投顾人员权限加载中

工作操作

调用已授权的投顾分析能力,结果仅作为方案草案

选择一项操作,结果将在这里展示。

已发布方案

服务端已审核并发布的客户方案

工作边界

建议生成与审核留痕

  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index f70e9cc..08266b5 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -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'; diff --git a/app/static/portal/employee-operations/dashboard/dashboard.js b/app/static/portal/employee-operations/dashboard/dashboard.js index 83562cb..c239965 100644 --- a/app/static/portal/employee-operations/dashboard/dashboard.js +++ b/app/static/portal/employee-operations/dashboard/dashboard.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'; diff --git a/app/static/portal/employee-risk/dashboard/dashboard.js b/app/static/portal/employee-risk/dashboard/dashboard.js index 2be8a17..0805de5 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.js +++ b/app/static/portal/employee-risk/dashboard/dashboard.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'; diff --git a/app/static/portal/guest/home/home.js b/app/static/portal/guest/home/home.js index 1ca4f6c..b4d1b93 100644 --- a/app/static/portal/guest/home/home.js +++ b/app/static/portal/guest/home/home.js @@ -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'); diff --git a/app/static/portal/guest/product-detail/index.html b/app/static/portal/guest/product-detail/index.html index 40e6a68..cbb84bd 100644 --- a/app/static/portal/guest/product-detail/index.html +++ b/app/static/portal/guest/product-detail/index.html @@ -9,7 +9,7 @@
-
返回基金列表

基金详情

+
返回基金列表

基金详情

@@ -18,6 +18,6 @@

风险与交易说明

风险等级
最小金额
交易方式场内市价模拟成交

基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。

- + diff --git a/app/static/portal/guest/product-detail/product-detail.js b/app/static/portal/guest/product-detail/product-detail.js index f6890b2..f7a29a3 100644 --- a/app/static/portal/guest/product-detail/product-detail.js +++ b/app/static/portal/guest/product-detail/product-detail.js @@ -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}`; diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index eb51a6e..515a95c 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -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 (