${escapeHtml(item.content || '--')}
- +${escapeHtml(item.content || '--')}
+ +diff --git a/app/api/controllers/risk.py b/app/api/controllers/risk.py index b9637f0..338dbd4 100644 --- a/app/api/controllers/risk.py +++ b/app/api/controllers/risk.py @@ -65,6 +65,16 @@ async def _idempotent_write( return await ApiTransactionService().execute_in(session, context, scope, key, body, action) +def _risk_list_envelope(page: dict[str, Any], context: RequestContext) -> dict[str, object]: + """在统一列表信封上补充风控列表总数和页大小。""" + response = _list_envelope(page, context) + meta = response["meta"] + if isinstance(meta, dict): + meta["total"] = int(page.get("total") or 0) + meta["page_size"] = int(page.get("page_size") or 0) + return response + + @router.get("/overview") async def risk_overview( context: RequestContext = Depends(build_request_context), # noqa: B008 @@ -81,7 +91,7 @@ async def list_risk_alerts( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskQueryService(session).list_alerts(context, query) - return _list_envelope(data, context) + return _risk_list_envelope(data, context) @router.post("/alerts/scan") @@ -231,7 +241,7 @@ async def list_risk_evidence( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskQueryService(session).list_evidence(context, source, query) - return _list_envelope(data, context) + return _risk_list_envelope(data, context) @router.get("/notifications") @@ -241,7 +251,7 @@ async def list_risk_notifications( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskNotificationService(session).list_notifications(context, query) - return _list_envelope(data, context) + return _risk_list_envelope(data, context) @router.post("/daily-report") diff --git a/app/repository/fund_query_repository.py b/app/repository/fund_query_repository.py index 1a069a3..46329fe 100644 --- a/app/repository/fund_query_repository.py +++ b/app/repository/fund_query_repository.py @@ -29,7 +29,7 @@ from enum import StrEnum from types import MappingProxyType from typing import Any, Final, Protocol, cast -from sqlalchemy import ColumnElement, Select, Table, and_, false, or_, select, true +from sqlalchemy import ColumnElement, Select, Table, and_, false, func, or_, select, true from sqlalchemy.ext.asyncio import AsyncSession from app.core.errors import ValidationAgentError @@ -135,6 +135,7 @@ class FundPage: limit: int offset: int next_offset: int | None + total: int | None = None @property def has_more(self) -> bool: @@ -495,6 +496,14 @@ class FundQueryRepository: rows = (await self.session.execute(statement)).mappings().all() has_more = len(rows) > request.limit visible = rows[: request.limit] + total = int( + await self.session.scalar( + select(func.count()).select_from( + statement.order_by(None).limit(None).offset(None).subquery() + ) + ) + or 0 + ) records = tuple( FundRecord(entity=alias, values=MappingProxyType(dict(row))) for row in visible ) @@ -504,6 +513,7 @@ class FundQueryRepository: limit=request.limit, offset=request.offset, next_offset=request.offset + request.limit if has_more else None, + total=total, ) def _statement( diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py index 5160c58..c995098 100644 --- a/app/repository/risk_repository.py +++ b/app/repository/risk_repository.py @@ -157,6 +157,12 @@ class RiskRepository: end_time=end_time, open_only=open_only, ) + total = int( + await self.session.scalar( + select(func.count()).select_from(statement.order_by(None).subquery()) + ) + or 0 + ) rows = ( await self.session.execute( statement.order_by( @@ -201,6 +207,7 @@ class RiskRepository: limit=request.limit, offset=request.offset, next_offset=request.offset + request.limit if has_more else None, + total=total, ) async def get_alert_detail(self, alert_no: str) -> FundRecord | None: @@ -813,6 +820,12 @@ class RiskRepository: entity: str, row_builder: Callable[..., dict[str, Any]], ) -> FundPage: + total = int( + await self.session.scalar( + select(func.count()).select_from(statement.order_by(None).subquery()) + ) + or 0 + ) rows = ( await self.session.execute( statement.limit(page.limit + 1).offset(page.offset) @@ -832,6 +845,7 @@ class RiskRepository: limit=page.limit, offset=page.offset, next_offset=page.offset + page.limit if has_more else None, + total=total, ) @staticmethod diff --git a/app/service/risk_query_service.py b/app/service/risk_query_service.py index 9e641e2..229dd93 100644 --- a/app/service/risk_query_service.py +++ b/app/service/risk_query_service.py @@ -200,6 +200,8 @@ class RiskQueryService: else None ), "has_more": page.has_more, + "total": int(page.total or 0), + "page_size": page.limit, } @classmethod diff --git a/app/static/portal/employee-risk/dashboard/dashboard.css b/app/static/portal/employee-risk/dashboard/dashboard.css index 1eb64d2..c9eb5b0 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.css +++ b/app/static/portal/employee-risk/dashboard/dashboard.css @@ -6,6 +6,9 @@ .risk-priority { font-variant-numeric: tabular-nums; } .risk-report-entry { display: grid; gap: var(--space-4); } .risk-report-entry p { margin: 0; color: var(--muted); line-height: 1.7; } +.operations-pagination { align-items: center; justify-content: space-between; } +.pagination-summary { color: var(--muted); font-size: var(--fs-small); } +.pagination-actions { display: flex; gap: var(--space-2); } .risk-assistant { padding: var(--space-5); } .risk-assistant__intro { display: flex; justify-content: space-between; gap: var(--space-4); } .risk-assistant__intro h2 { margin: 0; font-size: var(--fs-title); } @@ -25,6 +28,7 @@ .risk-message--error { color: var(--danger); background: var(--danger-soft); } .risk-message__tools { margin-top: var(--space-2); color: var(--muted); font-size: var(--fs-small); } .risk-prompts { margin: var(--space-3) 0; display: flex; flex-wrap: wrap; gap: var(--space-2); } +.risk-prompts[hidden], .risk-context-bar .button[hidden] { display: none !important; } .risk-prompts button { min-height: 34px; padding: 0 var(--space-3); color: var(--brand-dark); background: var(--brand-soft); border: 0; border-radius: var(--radius-sm); cursor: pointer; } .risk-prompts button:disabled { cursor: not-allowed; opacity: .55; } .risk-prompts--alert { padding-top: var(--space-2); border-top: 1px dashed var(--line); } @@ -78,12 +82,16 @@ .business-section__raw summary { color: var(--brand-dark); cursor: pointer; } .business-section__raw .json-view { margin-top: var(--space-2); } .operations-hero__identity { display: flex; align-items: center; gap: var(--space-2); } -.hero-meta-button { width: 26px; min-width: 26px; height: 26px; color: rgba(247,251,250,.9); background: rgba(255,255,255,.12); border-color: rgba(255,255,255,.25); } +.risk-notice-button { min-height: 34px; padding: 0 var(--space-3); display: inline-flex; align-items: center; gap: var(--space-2); color: #f7fbfa; background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.28); border-radius: 999px; cursor: pointer; font-size: var(--fs-small); font-weight: 700; transition: background 160ms ease, border-color 160ms ease, transform 160ms ease; } +.risk-notice-button:hover, .risk-notice-button:focus-visible { background: rgba(255,255,255,.2); border-color: rgba(255,255,255,.48); outline: none; transform: translateY(-1px); } +.risk-notice-button__icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.risk-notice-button__count { min-width: 20px; height: 20px; padding: 0 6px; display: inline-grid; place-items: center; color: #8c2e20; background: #ffe4df; border-radius: 999px; font-size: 11px; font-variant-numeric: tabular-nums; } .system-tip-list { display: grid; gap: var(--space-3); } .system-tip-item { padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); } +.system-tip-item__body { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; } .system-tip-item__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } .system-tip-item__header strong { overflow-wrap: anywhere; } .system-tip-item p { margin: var(--space-2) 0 0; color: var(--muted); line-height: 1.65; overflow-wrap: anywhere; } .system-tip-item__meta { margin-top: var(--space-2); display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--muted); font-size: var(--fs-small); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -@media (max-width: 620px) { .risk-assistant { padding: var(--space-4); } .risk-context-bar { align-items: flex-start; flex-direction: column; } .risk-alert-context-form { grid-template-columns: 1fr; } .risk-chat { height: 320px; } .risk-message { max-width: 92%; } .risk-composer { grid-template-columns: 1fr; } .risk-report-controls { align-items: stretch; flex-direction: column; } .risk-report-controls .button { width: 100%; } .data-table__detail-grid, .evidence-snapshot__grid, .business-record__grid { grid-template-columns: 1fr; } } +@media (max-width: 620px) { .risk-assistant { padding: var(--space-4); } .risk-context-bar { align-items: flex-start; flex-direction: column; } .risk-alert-context-form { grid-template-columns: 1fr; } .risk-chat { height: 320px; } .risk-message { max-width: 92%; } .risk-composer { grid-template-columns: 1fr; } .risk-report-controls { align-items: stretch; flex-direction: column; } .risk-report-controls .button { width: 100%; } .data-table__detail-grid, .evidence-snapshot__grid, .business-record__grid { grid-template-columns: 1fr; } .system-tip-item__body { grid-template-columns: 1fr; } } diff --git a/app/static/portal/employee-risk/dashboard/dashboard.js b/app/static/portal/employee-risk/dashboard/dashboard.js index 40f4e3e..00031a6 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.js +++ b/app/static/portal/employee-risk/dashboard/dashboard.js @@ -1,4 +1,4 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260913-8'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913-11'; 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'; @@ -235,10 +235,17 @@ if (requireRiskStaff()) { current.next = meta.next_cursor || null; document.querySelector(`[data-${kind}-prev]`).disabled = current.page === 0; document.querySelector(`[data-${kind}-next]`).disabled = !current.next; + const total = Number(meta.total ?? 0); + const pageSize = Number(meta.page_size ?? (kind === 'alert' ? 5 : 10)); + const totalPages = total > 0 && pageSize > 0 ? Math.ceil(total / pageSize) : 0; + document.querySelector(`[data-${kind}-summary]`).textContent = total > 0 + ? `第 ${current.page + 1} / ${totalPages} 页,共 ${total} 条` + : '共 0 条'; } async function loadAlerts() { renderLoading(alertTable, 4); + document.querySelector('[data-alert-summary]').textContent = '正在加载'; try { const response = await apiClient.get('RK002', { query: { ...state.alert.filters, cursor: state.alert.cursors[state.alert.page], limit: 5 } }); const items = Array.isArray(response.data) ? response.data : []; @@ -263,6 +270,7 @@ if (requireRiskStaff()) { async function loadEvidence() { renderLoading(evidenceTable, 4); + document.querySelector('[data-evidence-summary]').textContent = '正在加载'; try { updateEvidenceFilterVisibility(); const response = await apiClient.get('RK004', { pathParams: { source: state.evidence.source }, query: { ...state.evidence.filters, cursor: state.evidence.cursors[state.evidence.page], limit: 10 } }); @@ -281,6 +289,7 @@ if (requireRiskStaff()) { async function loadNotifications() { renderLoading(notificationTable, 4); + document.querySelector('[data-notification-summary]').textContent = '正在加载'; try { const response = await apiClient.get('RK005', { query: { ...state.notification.filters, cursor: state.notification.cursors[state.notification.page], limit: 10 } }); const items = Array.isArray(response.data) ? response.data : []; @@ -574,7 +583,7 @@ if (requireRiskStaff()) { try { const response = await apiClient.post('RK006', {}); showToast(`预警扫描完成,新建 ${response.data?.created_count ?? 0} 条`); - await Promise.all([loadOverview(), loadAlerts(), loadNotifications()]); + await Promise.all([loadOverview(), loadAlerts(), loadNotifications(), refreshSystemTipCount()]); } catch (error) { apiClient.reportError(error); showToast(error.message || '预警扫描失败', 'error'); @@ -801,23 +810,63 @@ if (requireRiskStaff()) { } } + function isStationNotification(item) { + return String(item?.channel || '') === '站内提醒'; + } + + function updateSystemTipCount(items) { + const count = Array.isArray(items) ? items.filter(isStationNotification).length : 0; + const badge = document.querySelector('[data-system-tip-count]'); + if (!badge) return; + badge.textContent = String(count); + badge.hidden = count === 0; + badge.title = count ? `最近站内提醒 ${count} 条` : '暂无站内提醒'; + } + + async function fetchStationNotifications() { + const response = await apiClient.get('RK005', { query: { limit: 10 } }); + const items = Array.isArray(response.data) ? response.data : []; + const stationItems = items.filter(isStationNotification); + updateSystemTipCount(stationItems); + return stationItems; + } + + async function refreshSystemTipCount() { + try { + await fetchStationNotifications(); + } catch (error) { + updateSystemTipCount([]); + apiClient.reportError(error); + } + } + async function openSystemTips() { const target = document.querySelector('[data-system-tips-list]'); if (!systemTipsDialog.open) systemTipsDialog.showModal(); renderLoading(target, 3); try { - const response = await apiClient.get('RK005', { query: { limit: 10 } }); - const items = Array.isArray(response.data) ? response.data : []; + const items = await fetchStationNotifications(); if (!items.length) { - renderEmpty(target, '暂无系统提示', '当前没有新的系统或通知记录。'); + renderEmpty(target, '暂无站内提醒', '当前没有新的站内提醒。'); return; } target.innerHTML = `
${escapeHtml(item.content || '--')}
- +${escapeHtml(item.content || '--')}
+ +