feat: 接入运维真实数据与三角色权限链路

- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
郑龙捷
2026-09-02 15:02:47 +08:00
parent 6d044784ec
commit 74dd97428f
51 changed files with 2142 additions and 1062 deletions
-1
View File
@@ -28,7 +28,6 @@ OPERATIONS_CACHE_TTL_SECONDS=300
OPERATIONS_REDIS_PREFIX=model-platform:operations
OPERATIONS_EVENT_STREAM=model-platform:operations:events
OPERATIONS_EVENT_STREAM_MAXLEN=10000
OPERATIONS_DATA_MODE=database
JWT_SECRET=change-this-development-secret
# AES-256 configuration decryption key used only when a value is wrapped in ENC(...).
+5 -3
View File
@@ -16,10 +16,11 @@
仅将库名切换为 `model_operations`
- `PLATFORM_READ_DATABASE_URL``DEPLOY_DATABASE_URL` 使用独立只读
SessionMySQL 事务会显式执行 `SET TRANSACTION READ ONLY`
- 套 SQLAlchemy engine/session factory 完全分离,运维路由只读写 `ops_*`
- 套 SQLAlchemy engine/session factory 完全分离:模型、版本和银行基础信息从
`model_deploy` 只读查询,监控结果与运维业务数据从 `model_operations` 查询或写入。
- 当前已落地 P0 ORM,以及模型列表、模型详情、指定月份监控结果三条接口。
- `OPERATIONS_DATA_MODE=mock` 时仍走真实鉴权、Workspace校验和API响应,
仅将业务查询替换为后端Mock数据;切换为 `database` 后读取运维库
- 运维模型接口联查 `model_deploy``model_operations`;模型方写入部署/银行/版本
信息和监控结果后,列表、模型详情、工作台及指定月份监控结果由后端统一返回
- Redis作为可选加速层接入:缓存聚合查询并承载通知 Streams;MySQL
Outbox仍是可靠事实源,Redis不可用时主接口继续运行。
@@ -42,4 +43,5 @@ GET /api/v1/operations/health
GET /api/v1/operations/models?workspace_id=<ULID>
GET /api/v1/operations/models/{model_id}?workspace_id=<ULID>
GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM&workspace_id=<ULID>
GET /api/v1/operations/workbench?workspace_id=<ULID>
```
@@ -2,9 +2,11 @@ from fastapi import APIRouter
from backend.api.operations.health import router as health_router
from backend.api.operations.models import router as models_router
from backend.api.operations.workbench import router as workbench_router
router = APIRouter(prefix="/api/v1/operations", tags=["operations"])
router.include_router(health_router)
router.include_router(models_router)
router.include_router(workbench_router)
__all__ = ["router"]
+41 -1
View File
@@ -6,8 +6,9 @@ from typing import Literal
from common.config import settings
from common.db import readonly_session_scope, session_scope
from common.db.models import Roles
from common.db.models import Permissions, RolePermissions, Roles
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import RequestContext, request_context
@@ -32,7 +33,10 @@ class OperationsContext:
request_id: str
user_id: str
workspace_id: str
workspace_code: str
workspace_name: str
role: OperationsRole
permissions: frozenset[str]
@property
def is_admin(self) -> bool:
@@ -120,19 +124,55 @@ async def operations_context(
"message": f"当前登录角色不支持运维模块:{raw_role}",
},
)
role_id = context.user.platform_role_id or context.role.role_id
permission_rows = await platform_session.execute(
select(Permissions.permission_code)
.join(
RolePermissions,
RolePermissions.permission_id == Permissions.permission_id,
)
.where(
RolePermissions.role_id == role_id,
RolePermissions.is_deleted == 0,
Permissions.is_deleted == 0,
)
)
return OperationsContext(
request_id=context.request_id,
user_id=context.user.user_id,
workspace_id=context.workspace.workspace_id,
workspace_code=context.workspace.workspace_code,
workspace_name=context.workspace.workspace_name,
role=normalized,
permissions=frozenset(row[0] for row in permission_rows),
)
def require_operations_permission(permission_code: str):
"""Create a dependency that enforces one operation page/action permission."""
async def dependency(
context: OperationsContext = Depends(operations_context),
) -> OperationsContext:
if context.is_admin or permission_code in context.permissions:
return context
raise HTTPException(
status.HTTP_403_FORBIDDEN,
{
"code": "OPERATIONS_PERMISSION_DENIED",
"message": f"缺少运维权限:{permission_code}",
},
)
return dependency
__all__ = [
"OperationsContext",
"deploy_database_session",
"operations_cache",
"operations_context",
"require_operations_permission",
"operations_database_session",
"operations_event_stream",
"operations_platform_database_session",
+48 -4
View File
@@ -9,6 +9,23 @@ from common.db import readonly_session_scope, session_scope
router = APIRouter()
_REQUIRED_TABLES = {
"model_operations": frozenset(
{
"ops_monitor_batches",
"ops_monitor_results",
}
),
"model_deploy": frozenset(
{
"model_bank",
"model_deploy",
"model_deploy_bank_map",
"model_version",
}
),
}
@router.get("/health")
async def operations_health(
@@ -30,10 +47,37 @@ async def operations_health(
try:
async with scope(factory) as session:
current_database = await session.scalar(text("SELECT DATABASE()"))
checks[database_name] = {
"status": "ok" if current_database == database_name else "error",
"mode": "read_only" if read_only else "read_write",
}
missing_tables: set[str] = set()
required_tables = _REQUIRED_TABLES.get(database_name)
if required_tables and current_database == database_name:
existing_tables = set(
(
await session.scalars(
text(
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE()"
)
)
).all()
)
missing_tables = required_tables - existing_tables
if current_database != database_name:
checks[database_name] = {
"status": "error",
"mode": "read_only" if read_only else "read_write",
"detail": "连接到的数据库名称不匹配",
}
elif missing_tables:
checks[database_name] = {
"status": "error",
"mode": "read_only" if read_only else "read_write",
"detail": f"缺少表:{', '.join(sorted(missing_tables))}",
}
else:
checks[database_name] = {
"status": "ok",
"mode": "read_only" if read_only else "read_write",
}
except Exception:
checks[database_name] = {
"status": "error",
+74 -33
View File
@@ -3,30 +3,45 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.operations._deps import (
OperationsContext,
operations_context,
deploy_database_session,
operations_database_session,
require_operations_permission,
)
from backend.schemas.operations import ModelCategoryCode, ModelStatusLabel
from backend.services.operations import (
get_model,
get_monthly_monitoring_result,
list_models,
get_deploy_model,
get_deploy_monthly_monitoring_result,
list_deploy_models,
parse_monitor_month,
)
from backend.services.operations.mock_data import (
get_mock_model,
get_mock_monthly_result,
list_mock_models,
)
from common.config import settings
router = APIRouter()
def _schema_error(exc: SQLAlchemyError) -> HTTPException | None:
"""Turn a missing operations table into an actionable service error."""
original = getattr(exc, "orig", None)
error_code = original.args[0] if getattr(original, "args", None) else None
if error_code != 1146: # MySQL ER_NO_SUCH_TABLE
return None
return HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
{
"code": "OPERATIONS_SCHEMA_NOT_READY",
"message": (
"模型部署库或运维监控结果表尚未完成初始化,请先确认"
" model_deploy、model_version、model_bank、model_deploy_bank_map "
"以及 model_operations 的监控批次/结果表"
),
},
)
def _envelope(context: OperationsContext, data: Any) -> dict[str, Any]:
return {"request_id": context.request_id, "data": data, "meta": {}}
@@ -37,32 +52,49 @@ async def list_operation_models(
category: ModelCategoryCode | None = Query(default=None),
model_status: ModelStatusLabel | None = Query(default=None, alias="status"),
keyword: str | None = Query(default=None, max_length=200),
context: OperationsContext = Depends(operations_context),
context: OperationsContext = Depends(require_operations_permission("operations:model-overview:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
models = list_mock_models(
bank=bank,
category=category,
status=model_status,
keyword=keyword,
) if settings.operations_data_mode == "mock" else await list_models(
session,
context.workspace_id,
bank=bank,
category=category,
status=model_status,
keyword=keyword,
)
try:
models = await list_deploy_models(
session,
deploy_session,
context.workspace_id,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
bank=bank,
category=category,
status=model_status,
keyword=keyword,
)
except SQLAlchemyError as exc:
if schema_error := _schema_error(exc):
raise schema_error from exc
raise
return _envelope(context, models)
@router.get("/models/{model_id}")
async def get_operation_model(
model_id: str,
context: OperationsContext = Depends(operations_context),
context: OperationsContext = Depends(require_operations_permission("operations:monitoring-detail:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
model = get_mock_model(model_id) if settings.operations_data_mode == "mock" else await get_model(session, context.workspace_id, model_id)
try:
model = await get_deploy_model(
session,
deploy_session,
context.workspace_id,
model_id,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
)
except SQLAlchemyError as exc:
if schema_error := _schema_error(exc):
raise schema_error from exc
raise
if model is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
@@ -75,8 +107,9 @@ async def get_operation_model(
async def get_operation_model_monitor_result(
model_id: str,
month: str = Query(..., pattern=r"^\d{4}-\d{2}$"),
context: OperationsContext = Depends(operations_context),
context: OperationsContext = Depends(require_operations_permission("operations:monitoring-detail:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
try:
monitor_month = parse_monitor_month(month)
@@ -85,12 +118,20 @@ async def get_operation_model_monitor_result(
status.HTTP_422_UNPROCESSABLE_ENTITY,
{"code": "INVALID_MONITOR_MONTH", "message": str(exc)},
) from exc
result = get_mock_monthly_result(model_id, month) if settings.operations_data_mode == "mock" else await get_monthly_monitoring_result(
session,
context.workspace_id,
model_id,
monitor_month,
)
try:
result = await get_deploy_monthly_monitoring_result(
session,
deploy_session,
context.workspace_id,
model_id,
monitor_month,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
)
except SQLAlchemyError as exc:
if schema_error := _schema_error(exc):
raise schema_error from exc
raise
if result is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.operations._deps import (
OperationsContext,
deploy_database_session,
operations_database_session,
require_operations_permission,
)
from backend.services.operations import get_workbench
router = APIRouter()
@router.get("/workbench")
async def get_operation_workbench(
context: OperationsContext = Depends(require_operations_permission("operations:workbench:view")),
session: AsyncSession = Depends(operations_database_session),
deploy_session: AsyncSession = Depends(deploy_database_session),
) -> dict[str, Any]:
data = await get_workbench(
session,
deploy_session,
context.workspace_id,
workspace_code=context.workspace_code,
workspace_name=context.workspace_name,
role=context.role,
)
return {
"request_id": context.request_id,
"data": data,
"meta": {},
}
__all__ = ["router"]
@@ -1,4 +1,9 @@
from backend.services.operations.cache import OperationsCache
from backend.services.operations.deploy_model_queries import (
get_deploy_model,
get_deploy_monthly_monitoring_result,
list_deploy_models,
)
from backend.services.operations.model_queries import (
get_model,
get_monthly_monitoring_result,
@@ -6,12 +11,17 @@ from backend.services.operations.model_queries import (
parse_monitor_month,
)
from backend.services.operations.streams import OperationsEventStream
from backend.services.operations.workbench_queries import get_workbench
__all__ = [
"OperationsCache",
"OperationsEventStream",
"get_deploy_model",
"get_deploy_monthly_monitoring_result",
"get_workbench",
"get_model",
"get_monthly_monitoring_result",
"list_models",
"list_deploy_models",
"parse_monitor_month",
]
@@ -0,0 +1,482 @@
from __future__ import annotations
import datetime
from dataclasses import dataclass
from typing import Any
from sqlalchemy import and_, case, func, select, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.schemas.operations import MonthlyMonitoringResultDto, OperationsModelDto
from common.db.models.operations import (
OpsMonitorBatch,
OpsMonitorEvaluation,
OpsMonitorResult,
OpsMonitorReview,
)
_MODEL_STATUS = {
0: "下线", # 离线
1: "正常", # 在线
2: "正常", # 部署中;陪跑由 role=2 单独判断
3: "下线", # 已下线
}
_RANKING_STATUS = {
"matched": "相符",
"unmatched": "不符",
"not_applicable": "",
None: "",
}
_CATEGORY_CODES = {"std", "bai", "big", "afd"}
@dataclass(frozen=True)
class _MonitorSnapshot:
monitor_result_id: str
model_instance_id: str
model_version_id: str
monitor_month: datetime.date
ranking_result: str | None
ks_value: Any
psi_value: Any
ks_mom_drop_rate: Any
secondary_level2_hits_6m: int
action_snapshot: str | None
handled_at: datetime.datetime | None
source_result_ref: str | None
monitor_grade: str | None
abnormal_level: str | None
pending_model_review: bool
pending_business_review: bool
_DEPLOY_MODEL_SQL = text(
"""
SELECT
deploy.id AS deploy_id,
deploy.code AS deploy_code,
deploy.name AS deploy_name,
deploy.modelType AS model_type,
deploy.status AS deploy_status,
deploy.role AS deploy_role,
deploy.currentVersion AS current_version_no,
deploy.group_code AS group_code,
version.id AS version_id,
version.version AS version_no,
version.versionName AS version_name,
COALESCE(
version.gmtModified,
version.gmtCreated,
deploy.gmtModified,
deploy.gmtCreated
) AS last_iteration_at,
bank.bankNo AS bank_no,
bank.bankName AS bank_name,
bank.isWuji AS is_wuji_bank
FROM model_deploy AS deploy
INNER JOIN model_deploy_bank_map AS bank_map
ON bank_map.deployId = deploy.id
AND bank_map.status = 1
INNER JOIN model_bank AS bank
ON bank.bankNo = bank_map.bankNo
AND bank.status = 1
LEFT JOIN model_version AS version
ON version.id = (
SELECT candidate.id
FROM model_version AS candidate
WHERE candidate.deployId = deploy.id
ORDER BY candidate.isCurrent DESC, candidate.version DESC, candidate.id DESC
LIMIT 1
)
LEFT JOIN sys_project_space AS project_space
ON project_space.id = deploy.space_id
WHERE (
project_space.name = :workspace_name
OR project_space.name = :workspace_code
OR deploy.group_code = :workspace_code
)
AND (
:model_id = ''
OR deploy.code = :model_id
OR CAST(deploy.id AS CHAR) = :model_id
)
ORDER BY bank.bankName, deploy.name, deploy.id
"""
)
def _latest_result_statement(
workspace_id: str,
monitor_month: datetime.date | None = None,
):
filters = [
OpsMonitorBatch.workspace_id == workspace_id,
OpsMonitorBatch.batch_status == "published",
OpsMonitorBatch.is_deleted == 0,
OpsMonitorResult.workspace_id == workspace_id,
OpsMonitorResult.is_deleted == 0,
]
if monitor_month is not None:
filters.append(OpsMonitorResult.monitor_month == monitor_month)
ranked = (
select(
OpsMonitorResult.monitor_result_id.label("monitor_result_id"),
OpsMonitorResult.model_instance_id.label("model_instance_id"),
OpsMonitorResult.model_version_id.label("model_version_id"),
OpsMonitorResult.monitor_month.label("monitor_month"),
OpsMonitorResult.ranking_result.label("ranking_result"),
OpsMonitorResult.ks_value.label("ks_value"),
OpsMonitorResult.psi_value.label("psi_value"),
OpsMonitorResult.source_result_ref.label("source_result_ref"),
func.row_number()
.over(
partition_by=OpsMonitorResult.model_instance_id,
order_by=(
OpsMonitorResult.monitor_month.desc(),
OpsMonitorBatch.revision_no.desc(),
OpsMonitorBatch.published_at.desc(),
),
)
.label("row_no"),
)
.join(OpsMonitorBatch, OpsMonitorBatch.batch_id == OpsMonitorResult.batch_id)
.where(*filters)
.subquery("ranked_monitor_results")
)
latest_review = (
select(
OpsMonitorReview.monitor_result_id.label("monitor_result_id"),
func.max(OpsMonitorReview.handled_at).label("handled_at"),
func.max(
case(
(and_(
OpsMonitorReview.review_stage == "model_initial",
OpsMonitorReview.review_status == "pending",
), 1),
else_=0,
)
).label("pending_model_review"),
func.max(
case(
(and_(
OpsMonitorReview.review_stage == "business_final",
OpsMonitorReview.review_status == "pending",
), 1),
else_=0,
)
).label("pending_business_review"),
)
.where(
OpsMonitorReview.is_deleted == 0,
)
.group_by(OpsMonitorReview.monitor_result_id)
.subquery("latest_monitor_review")
)
return (
select(
ranked.c.monitor_result_id,
ranked.c.model_instance_id,
ranked.c.model_version_id,
ranked.c.monitor_month,
ranked.c.ranking_result,
ranked.c.ks_value,
ranked.c.psi_value,
ranked.c.source_result_ref,
OpsMonitorEvaluation.ks_mom_drop_rate,
OpsMonitorEvaluation.secondary_level2_hits_6m,
OpsMonitorEvaluation.action_snapshot,
latest_review.c.handled_at,
OpsMonitorEvaluation.monitor_grade,
OpsMonitorEvaluation.abnormal_level,
latest_review.c.pending_model_review,
latest_review.c.pending_business_review,
)
.outerjoin(
OpsMonitorEvaluation,
and_(
OpsMonitorEvaluation.monitor_result_id
== ranked.c.monitor_result_id,
OpsMonitorEvaluation.is_current == 1,
OpsMonitorEvaluation.is_deleted == 0,
),
)
.outerjoin(
latest_review,
latest_review.c.monitor_result_id == ranked.c.monitor_result_id,
)
.where(ranked.c.row_no == 1)
)
async def _load_deploy_rows(
deploy_session: AsyncSession,
*,
workspace_code: str,
workspace_name: str,
model_id: str = "",
) -> list[dict[str, Any]]:
try:
rows = (
await deploy_session.execute(
_DEPLOY_MODEL_SQL,
{
"workspace_code": workspace_code,
"workspace_name": workspace_name,
"model_id": model_id,
},
)
).mappings()
except SQLAlchemyError as exc:
if _is_missing_table_error(exc):
return []
raise
return [dict(row) for row in rows]
async def _load_monitor_snapshots(
operations_session: AsyncSession,
workspace_id: str,
monitor_month: datetime.date | None = None,
) -> dict[str, _MonitorSnapshot]:
try:
rows = (await operations_session.execute(
_latest_result_statement(workspace_id, monitor_month)
)).mappings()
except SQLAlchemyError as exc:
if _is_missing_table_error(exc):
return {}
raise
snapshots: dict[str, _MonitorSnapshot] = {}
for row in rows:
snapshot = _MonitorSnapshot(
monitor_result_id=str(row["monitor_result_id"]),
model_instance_id=str(row["model_instance_id"]),
model_version_id=str(row["model_version_id"]),
monitor_month=row["monitor_month"],
ranking_result=row["ranking_result"],
ks_value=row["ks_value"],
psi_value=row["psi_value"],
ks_mom_drop_rate=row["ks_mom_drop_rate"],
secondary_level2_hits_6m=int(row["secondary_level2_hits_6m"] or 0),
action_snapshot=row["action_snapshot"],
handled_at=row["handled_at"],
source_result_ref=row["source_result_ref"],
monitor_grade=row["monitor_grade"],
abnormal_level=row["abnormal_level"],
pending_model_review=bool(row["pending_model_review"]),
pending_business_review=bool(row["pending_business_review"]),
)
for alias in (
snapshot.monitor_result_id,
snapshot.model_instance_id,
snapshot.model_version_id,
snapshot.source_result_ref,
):
if alias:
snapshots[str(alias)] = snapshot
return snapshots
def _snapshot_for(
row: dict[str, Any], snapshots: dict[str, _MonitorSnapshot]
) -> _MonitorSnapshot | None:
aliases = (
row["deploy_id"],
row["version_id"],
row["deploy_code"],
f"deploy:{row['deploy_id']}",
f"version:{row['version_id']}" if row["version_id"] is not None else None,
)
for alias in aliases:
if alias is not None and str(alias) in snapshots:
return snapshots[str(alias)]
return None
def _category_code(row: dict[str, Any]) -> str:
candidates = (
str(row.get("group_code") or "").lower(),
str(row.get("deploy_code") or "").lower(),
str(row.get("deploy_name") or "").lower(),
)
for candidate in candidates:
if candidate in _CATEGORY_CODES:
return candidate
for code in _CATEGORY_CODES:
if code in candidate:
return code
# modelType=1 is the deployment platform's scoring-card type. Until the
# model side supplies a business category, keep it in the standard A-card
# bucket so the existing four-category API remains usable.
return "std"
def _model_status(row: dict[str, Any]) -> str:
if row.get("deploy_role") == 2 and row.get("deploy_status") not in {0, 3}:
return "陪跑"
return _MODEL_STATUS.get(row.get("deploy_status"), "正常")
def _percent(value: Any) -> float:
return round(float(value or 0) * 100, 4)
def _date_text(value: datetime.date | datetime.datetime | None) -> str:
if value is None:
return ""
return (value.date() if isinstance(value, datetime.datetime) else value).isoformat()
def _datetime_date_text(value: datetime.datetime | None) -> str | None:
return value.date().isoformat() if value else None
def _is_missing_table_error(exc: SQLAlchemyError) -> bool:
original = getattr(exc, "orig", None)
error_code = original.args[0] if getattr(original, "args", None) else None
return error_code == 1146 # MySQL ER_NO_SUCH_TABLE
def _model_dto(
row: dict[str, Any], snapshot: _MonitorSnapshot | None
) -> OperationsModelDto:
return OperationsModelDto(
model_instance_id=str(row["deploy_id"]),
bank_name=str(row["bank_name"]),
model_category=_category_code(row), # type: ignore[arg-type]
model_name=str(row["deploy_name"]),
model_id=str(row["deploy_code"] or row["deploy_id"]),
model_version=str(
row["version_name"]
or (f"v{row['version_no']}" if row["version_no"] is not None else "")
),
model_status=_model_status(row), # type: ignore[arg-type]
last_iteration_date=_date_text(row["last_iteration_at"]),
ranking_result=_RANKING_STATUS.get(
snapshot.ranking_result if snapshot else None, ""
),
ks=_percent(snapshot.ks_value if snapshot else None),
psi=_percent(snapshot.psi_value if snapshot else None),
ks_mom_drop=_percent(snapshot.ks_mom_drop_rate if snapshot else None),
secondary_hits_6m=snapshot.secondary_level2_hits_6m if snapshot else 0,
is_wuji_bank=bool(row["is_wuji_bank"]),
last_processed_at=(
_datetime_date_text(snapshot.handled_at) if snapshot else None
),
previous_advice=(
snapshot.action_snapshot if snapshot and snapshot.action_snapshot
else "暂无历史处理建议"
),
common_model_name=None,
)
async def list_deploy_models(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
*,
workspace_code: str,
workspace_name: str,
bank: str | None = None,
category: str | None = None,
status: str | None = None,
keyword: str | None = None,
) -> list[OperationsModelDto]:
rows = await _load_deploy_rows(
deploy_session,
workspace_code=workspace_code,
workspace_name=workspace_name,
)
snapshots = await _load_monitor_snapshots(operations_session, workspace_id)
term = keyword.strip().lower() if keyword else ""
result: list[OperationsModelDto] = []
for row in rows:
model = _model_dto(row, _snapshot_for(row, snapshots))
if bank and model.bank_name != bank:
continue
if category and model.model_category != category:
continue
if status and model.model_status != status:
continue
if term and term not in " ".join(
(model.bank_name, model.model_name, model.model_id, model.model_version)
).lower():
continue
result.append(model)
return result
async def get_deploy_model(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
model_id: str,
*,
workspace_code: str,
workspace_name: str,
) -> OperationsModelDto | None:
models = await list_deploy_models(
operations_session,
deploy_session,
workspace_id,
workspace_code=workspace_code,
workspace_name=workspace_name,
keyword=model_id,
)
return next((model for model in models if model.model_id == model_id), None)
async def get_deploy_monthly_monitoring_result(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
model_id: str,
monitor_month: datetime.date,
*,
workspace_code: str,
workspace_name: str,
) -> MonthlyMonitoringResultDto | None:
rows = await _load_deploy_rows(
deploy_session,
workspace_code=workspace_code,
workspace_name=workspace_name,
model_id=model_id,
)
row = next(
(
item
for item in rows
if str(item["deploy_code"] or item["deploy_id"]) == model_id
),
None,
)
if row is None:
return None
snapshots = await _load_monitor_snapshots(
operations_session,
workspace_id,
monitor_month,
)
snapshot = _snapshot_for(row, snapshots)
if snapshot is None:
return None
return MonthlyMonitoringResultDto(
model_instance_id=str(row["deploy_id"]),
monitor_month=snapshot.monitor_month.strftime("%Y-%m"),
ranking_result=_RANKING_STATUS.get(snapshot.ranking_result, ""),
ks=_percent(snapshot.ks_value),
psi=_percent(snapshot.psi_value),
ks_mom_drop=_percent(snapshot.ks_mom_drop_rate),
secondary_hits_6m=snapshot.secondary_level2_hits_6m,
)
__all__ = [
"get_deploy_model",
"get_deploy_monthly_monitoring_result",
"list_deploy_models",
]
@@ -1,105 +0,0 @@
from __future__ import annotations
import datetime
from backend.schemas.operations import MonthlyMonitoringResultDto, OperationsModelDto
_MODEL_SPECS = [
("江城银行", "std", "标准A卡", "JC-STD-001", "v2.3", "正常", "2026-06-18", "不符", 36.84, 27.40, 24.10, 3, True, "2026-06-18", "建议启动模型微调或重构评估", "标准A卡通用版 v2.3"),
("滨海银行", "std", "标准A卡", "BH-STD-001", "v2.3", "陪跑", "2026-07-28", "相符", 44.10, 6.20, 3.40, 0, False, "2026-06-03", "维持现状,继续监控", "标准A卡通用版 v2.3"),
("华东银行", "std", "标准A卡", "HD-STD-001", "v2.2", "正常", "2026-03-20", "相符", 43.50, 8.80, 6.10, 0, False, "2026-05-20", "维持现状,继续监控", "标准A卡通用版 v2.3"),
("南岭银行", "std", "标准A卡", "NL-STD-001", "v1.6", "正常", "2025-08-22", "相符", 41.12, 13.60, 22.50, 1, False, "2026-06-12", "建议重点关注 PSI 与 KS 环比变化", "标准A卡通用版 v2.3"),
("云岭银行", "std", "标准A卡", "YL-STD-001", "v2.1", "正常", "2026-01-09", "相符", 45.80, 5.10, 2.20, 0, False, "2026-04-28", "维持现状,继续监控", "标准A卡通用版 v2.3"),
("通汇银行", "std", "标准A卡", "TH-STD-001", "v2.0", "正常", "2025-11-30", "不符", 38.73, 9.40, 11.80, 4, False, "2026-05-16", "建议开展模型微调可行性评估", "标准A卡通用版 v2.3"),
("北岸银行", "std", "标准A卡", "BA-STD-001", "v1.9", "下线", "2024-12-11", "", 0.00, 0.00, 0.00, 0, False, "2026-03-02", "模型已下线,不再参与月度监控", None),
("江城银行", "big", "大额A卡", "JC-BIG-001", "v1.4", "正常", "2026-07-03", "相符", 46.30, 4.80, 1.50, 0, False, "2026-06-24", "维持现状,继续监控", "大额A卡通用版 v1.4"),
("华东银行", "big", "大额A卡", "HD-BIG-001", "v1.3", "正常", "2026-04-16", "相符", 44.90, 7.30, 4.90, 0, False, "2026-05-30", "维持现状,继续监控", "大额A卡通用版 v1.4"),
("云岭银行", "big", "大额A卡", "YL-BIG-001", "v1.2", "陪跑结束", "2026-08-05", "相符", 42.70, 9.60, 8.20, 0, False, None, "暂无历史处理建议", "大额A卡通用版 v1.4"),
("滨海银行", "big", "大额A卡", "BH-BIG-001", "v1.1", "正常", "2025-09-25", "相符", 39.40, 11.20, 9.70, 2, False, "2026-04-19", "建议持续跟踪,下期复核", "大额A卡通用版 v1.4"),
("南岭银行", "bai", "白户A卡", "NL-BAI-001", "v1.5", "正常", "2026-05-22", "不符", 34.20, 18.70, 15.30, 2, True, "2026-06-09", "建议启动模型微调或重构评估", None),
("通汇银行", "bai", "白户A卡", "TH-BAI-001", "v1.4", "正常", "2026-02-11", "相符", 37.60, 12.10, 7.40, 1, False, "2026-05-11", "建议重点关注并持续跟踪", "白户A卡通用版 v1.4"),
("江城银行", "bai", "白户A卡", "JC-BAI-001", "v1.3", "正常", "2025-12-05", "相符", 40.80, 9.90, 5.60, 0, False, "2026-03-26", "维持现状,继续监控", "白户A卡通用版 v1.4"),
("华东银行", "afd", "反欺诈评分", "HD-AFD-001", "v2.0", "正常", "2026-04-10", "不符", 28.60, 31.50, 18.90, 3, False, "2026-06-21", "建议启动模型微调或重构评估", None),
("滨海银行", "afd", "反欺诈评分", "BH-AFD-001", "v1.8", "正常", "2025-10-30", "相符", 36.40, 8.10, 4.20, 3, True, "2026-05-25", "建议重点关注并持续跟踪", "反欺诈评分通用版 v1.8"),
("云岭银行", "afd", "反欺诈评分", "YL-AFD-001", "v1.7", "正常", "2025-08-19", "相符", 38.20, 14.60, 12.70, 2, False, "2026-04-15", "建议持续跟踪,下期复核", None),
]
def _model(spec: tuple[object, ...]) -> OperationsModelDto:
bank, category, name, model_id, version, model_status, iteration_date, ranking, ks, psi, drop, hits, wuji, processed, advice, common = spec
return OperationsModelDto(
model_instance_id=f"mock-{model_id}",
bank_name=str(bank),
model_category=category, # type: ignore[arg-type]
model_name=str(name),
model_id=str(model_id),
model_version=str(version),
model_status=model_status, # type: ignore[arg-type]
last_iteration_date=str(iteration_date),
ranking_result=ranking, # type: ignore[arg-type]
ks=float(ks),
psi=float(psi),
ks_mom_drop=float(drop),
secondary_hits_6m=int(hits),
is_wuji_bank=bool(wuji),
last_processed_at=processed,
previous_advice=str(advice),
common_model_name=common,
)
MOCK_MODELS = tuple(_model(spec) for spec in _MODEL_SPECS)
_MODEL_BY_ID = {model.model_id: model for model in MOCK_MODELS}
def list_mock_models(
*,
bank: str | None = None,
category: str | None = None,
status: str | None = None,
keyword: str | None = None,
) -> list[OperationsModelDto]:
term = keyword.strip().lower() if keyword else ""
return [
model.model_copy()
for model in MOCK_MODELS
if not bank or model.bank_name == bank
if not category or model.model_category == category
if not status or model.model_status == status
if not term or term in " ".join((model.bank_name, model.model_name, model.model_id, model.model_version)).lower()
]
def get_mock_model(model_id: str) -> OperationsModelDto | None:
model = _MODEL_BY_ID.get(model_id)
return model.model_copy() if model else None
def get_mock_monthly_result(model_id: str, month: str) -> MonthlyMonitoringResultDto | None:
model = _MODEL_BY_ID.get(model_id)
if model is None:
return None
if model.model_status == "下线" and month != "2026-02":
return None
try:
index = max(0, min(5, (datetime.date.fromisoformat(f"{month}-01").year - 2026) * 12 + datetime.date.fromisoformat(f"{month}-01").month - 2))
except ValueError:
return None
distance = 5 - index
return MonthlyMonitoringResultDto(
model_instance_id=model.model_instance_id,
monitor_month=month,
ranking_result=model.ranking_result,
ks=round(max(0, model.ks - distance * 0.32), 2),
psi=round(max(0, model.psi - distance * 0.74), 2),
ks_mom_drop=round(max(0, model.ks_mom_drop - distance * 0.9), 2),
secondary_hits_6m=max(0, model.secondary_hits_6m - distance),
)
__all__ = [
"MOCK_MODELS",
"get_mock_model",
"get_mock_monthly_result",
"list_mock_models",
]
@@ -0,0 +1,303 @@
from __future__ import annotations
import datetime
from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.services.operations.deploy_model_queries import (
_MonitorSnapshot,
_load_monitor_snapshots,
list_deploy_models,
)
OperationsRole = Literal["admin", "model_team", "business_team"]
_REPORTS_SQL = text(
"""
SELECT report_id, monitor_result_id, report_status, report_month, output_date, updated_at
FROM ops_reports
WHERE workspace_id = :workspace_id AND is_deleted = 0
ORDER BY updated_at DESC, report_id DESC
"""
)
_WORKFLOWS_SQL = text(
"""
SELECT
workflow.workflow_id,
workflow.workflow_title,
workflow.current_stage,
workflow.workflow_status,
stage.stage_name_snapshot,
stage.stage_status,
stage.owner_role_code,
stage.confirmation_required,
stage.confirmation_status,
stage.due_at,
stage.updated_at
FROM ops_workflows AS workflow
LEFT JOIN ops_workflow_stages AS stage
ON stage.workflow_id = workflow.workflow_id
AND stage.stage_no = workflow.current_stage
AND stage.is_deleted = 0
WHERE workflow.workspace_id = :workspace_id
AND workflow.is_deleted = 0
ORDER BY workflow.updated_at DESC, workflow.workflow_id DESC
"""
)
_ACTIVITY_SQL = text(
"""
SELECT user_id, event_type, target_type, target_id, event_metadata, occurred_at
FROM ops_usage_events
WHERE workspace_id = :workspace_id AND is_deleted = 0
ORDER BY occurred_at DESC, usage_event_id DESC
LIMIT 7
"""
)
async def _optional_rows(
session: AsyncSession,
statement: Any,
workspace_id: str,
) -> list[dict[str, Any]]:
try:
rows = (await session.execute(statement, {"workspace_id": workspace_id})).mappings()
except SQLAlchemyError as exc:
original = getattr(exc, "orig", None)
code = original.args[0] if getattr(original, "args", None) else None
if code == 1146: # MySQL ER_NO_SUCH_TABLE: an uninitialised optional module
return []
raise
return [dict(row) for row in rows]
def _kpi(
key: str,
label: str,
value: int,
detail: str,
target: str,
tone: str = "normal",
) -> dict[str, Any]:
return {
"key": key,
"label": label,
"value": value,
"detail": detail,
"target": target,
"tone": tone,
}
def _model_for_snapshot(
snapshot: _MonitorSnapshot | None,
models_by_instance: dict[str, Any],
) -> Any | None:
return models_by_instance.get(snapshot.model_instance_id) if snapshot else None
def _review_todos(
snapshots: dict[str, _MonitorSnapshot],
models_by_instance: dict[str, Any],
role: OperationsRole,
) -> list[dict[str, Any]]:
todos: list[dict[str, Any]] = []
unique_snapshots = {
snapshot.monitor_result_id: snapshot for snapshot in snapshots.values()
}
for snapshot in unique_snapshots.values():
if snapshot.model_instance_id not in models_by_instance:
continue
pending = (
snapshot.pending_business_review
if role == "business_team"
else snapshot.pending_model_review
)
if role == "admin":
pending = snapshot.pending_model_review or snapshot.pending_business_review
if not pending:
continue
model = _model_for_snapshot(snapshot, models_by_instance)
stage = "业务团队终审" if role == "business_team" else "模型团队初审"
if role == "admin":
stage = "监控结果处理"
todos.append(
{
"id": f"review:{snapshot.monitor_result_id}:{stage}",
"tone": "danger" if snapshot.monitor_grade == "C" else "warning",
"title": f"监控结果待处理 · {model.bank} {model.model_name}",
"detail": f"{model.model_id} · {stage} · 等级 {snapshot.monitor_grade or ''}",
"action_label": "选择处理建议",
"target": f"/operations/monitoring/{model.model_id}",
}
)
return todos
def _report_todos(
reports: list[dict[str, Any]],
snapshots: dict[str, _MonitorSnapshot],
models_by_instance: dict[str, Any],
role: OperationsRole,
) -> list[dict[str, Any]]:
expected_status = "sent_business" if role == "business_team" else "pending_model_read"
todos: list[dict[str, Any]] = []
now = datetime.datetime.now()
for report in reports:
if report["report_status"] != expected_status:
continue
snapshot = snapshots.get(str(report["monitor_result_id"]))
model = _model_for_snapshot(snapshot, models_by_instance)
label = f"{model.bank} {model.model_name}" if model else "待关联模型报告"
updated_at = report.get("updated_at")
days = (now - updated_at).days if isinstance(updated_at, datetime.datetime) else 0
overdue = days >= 5
todos.append(
{
"id": f"report:{report['report_id']}",
"tone": "danger" if overdue else "warning",
"title": f"报告待阅读 · {label}",
"detail": f"{report['report_month'].strftime('%Y-%m')} · {'已超 5 个工作日' if overdue else '待阅读'}",
"action_label": "去阅读",
"target": f"/operations/reports?report={report['report_id']}",
}
)
return todos
def _workflow_items(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
todos: list[dict[str, Any]] = []
watches: list[dict[str, Any]] = []
now = datetime.datetime.now()
for row in rows:
if row.get("workflow_status") != "active":
continue
target = f"/operations/workflows?workflow={row['workflow_id']}"
due_at = row.get("due_at")
overdue_days = (now - due_at).days if isinstance(due_at, datetime.datetime) else 0
if overdue_days > 0 and row.get("stage_status") not in {"completed", "skipped"}:
watches.append(
{
"id": f"workflow:{row['workflow_id']}",
"tone": "primary",
"title": f"流程进度关注 · {row['workflow_title']}",
"detail": f"停在「{row.get('stage_name_snapshot') or '当前环节'}」已 {overdue_days}",
"action_label": "查看进度",
"target": target,
}
)
if (
row.get("owner_role_code") in {"business", "business_team", "biz"}
and row.get("confirmation_required") == 1
and row.get("confirmation_status") == "pending"
):
todos.append(
{
"id": f"workflow-feedback:{row['workflow_id']}",
"tone": "warning",
"title": f"流程待反馈 · {row['workflow_title']}",
"detail": f"当前环节:{row.get('stage_name_snapshot') or '待确认'}",
"action_label": "去反馈",
"target": target,
}
)
return todos, watches
def _activities(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
labels = {
"login": "登录平台",
"request_submit": "发起模型需求",
"report_read": "阅读报告",
"report_download": "下载报告",
}
return [
{
"occurred_at": row["occurred_at"].isoformat() if row.get("occurred_at") else "",
"actor": f"用户 {row['user_id']}" if row.get("user_id") else "系统",
"text": labels.get(row.get("event_type"), str(row.get("event_type") or "平台行为")),
}
for row in rows
]
async def get_workbench(
operations_session: AsyncSession,
deploy_session: AsyncSession,
workspace_id: str,
*,
workspace_code: str,
workspace_name: str,
role: OperationsRole,
) -> dict[str, Any]:
models = await list_deploy_models(
operations_session,
deploy_session,
workspace_id,
workspace_code=workspace_code,
workspace_name=workspace_name,
)
snapshots = await _load_monitor_snapshots(operations_session, workspace_id)
models_by_instance = {model.model_instance_id: model for model in models}
live_models = [model for model in models if model.model_status != "下线"]
monitored = [
model for model in live_models
if snapshots.get(model.model_instance_id) is not None
]
grades = {
grade: sum(
1 for model in monitored
if snapshots[model.model_instance_id].monitor_grade == grade
)
for grade in ("A", "B", "C")
}
bc_count = grades["B"] + grades["C"]
intervention = [model for model in monitored if model.ks < 30 or model.psi > 50]
reports = await _optional_rows(operations_session, _REPORTS_SQL, workspace_id)
workflows = await _optional_rows(operations_session, _WORKFLOWS_SQL, workspace_id)
activities = await _optional_rows(operations_session, _ACTIVITY_SQL, workspace_id)
workflow_todos, watches = _workflow_items(workflows)
todos = _review_todos(snapshots, models_by_instance, role)
todos.extend(_report_todos(reports, snapshots, models_by_instance, role))
if role == "business_team":
todos.extend(workflow_todos)
todos.sort(key=lambda item: (item["tone"] != "danger", item["id"]))
alerts = []
overdue_reports = len([item for item in todos if item["id"].startswith("report:") and item["tone"] == "danger"])
if overdue_reports:
alerts.append(f"{overdue_reports} 份报告已超 5 个工作日未阅读,已进入催办范围。")
if intervention:
alerts.append(f"另有 {len(intervention)} 个模型触发主动干预条件。")
category_count = len({model.model_category for model in live_models})
common = [
_kpi("models", "在管模型", len(live_models), f"{len({model.bank_name for model in live_models})} 家银行 · {category_count} 个大类", "/operations/models"),
_kpi("grades", "B / C 等级", bc_count, f"C {grades['C']} · B {grades['B']}", "/operations/monitoring?grade=B,C", "warning" if bc_count else "normal"),
_kpi("pending_reviews", "待处理结果", len(_review_todos(snapshots, models_by_instance, role)), "到期未处理即默认暂不处理", "/operations/monitoring", "warning" if todos else "normal"),
_kpi("intervention", "需主动干预", len(intervention), "KS < 30% 或 PSI > 50%", "/operations/monitoring", "danger" if intervention else "normal"),
]
if role == "business_team":
common.extend([
_kpi("feedback", "待我反馈", len(workflow_todos), "方案设计与评审确认", "/operations/workflows", "warning" if workflow_todos else "normal"),
_kpi("business_reports", "待阅读报告", len(_report_todos(reports, snapshots, models_by_instance, role)), "报告发送后待阅读", "/operations/reports", "warning" if reports else "normal"),
])
else:
model_reports = _report_todos(reports, snapshots, models_by_instance, role)
common.extend([
_kpi("model_reports", "待我阅读报告", len(model_reports), "超过 5 个工作日进入催办", "/operations/reports", "warning" if model_reports else "normal"),
_kpi("stalled_workflows", "流程停滞", len(watches), "超过节点期限未更新", "/operations/workflows", "warning" if watches else "normal"),
])
return {
"role": role,
"alerts": alerts,
"kpis": common,
"todos": todos[:20],
"watches": watches[:20],
"activities": _activities(activities),
}
__all__ = ["get_workbench"]
@@ -0,0 +1,73 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import ProgrammingError
from backend.api.operations._deps import OperationsContext, require_operations_permission
from backend.services.operations.deploy_model_queries import (
_load_deploy_rows,
_load_monitor_snapshots,
)
@pytest.mark.asyncio
async def test_missing_deploy_table_degrades_to_empty_models() -> None:
class MissingTableSession:
async def execute(self, *args, **kwargs):
raise ProgrammingError(
"SELECT ...",
{},
Exception(1146, "table does not exist"),
)
rows = await _load_deploy_rows(
MissingTableSession(),
workspace_code="001",
workspace_name="001",
)
assert rows == []
@pytest.mark.asyncio
async def test_missing_monitoring_table_degrades_to_empty_snapshots() -> None:
class MissingTableSession:
async def execute(self, *args, **kwargs):
raise ProgrammingError(
"SELECT ...",
{},
Exception(1146, "table does not exist"),
)
snapshots = await _load_monitor_snapshots(MissingTableSession(), "W" * 26)
assert snapshots == {}
@pytest.mark.asyncio
async def test_operations_permission_dependency_checks_effective_permissions() -> None:
allowed = OperationsContext(
request_id="R" * 26,
user_id="U" * 26,
workspace_id="W" * 26,
workspace_code="001",
workspace_name="001",
role="business_team",
permissions=frozenset({"operations:model-overview:view"}),
)
denied = OperationsContext(
request_id="R" * 26,
user_id="U" * 26,
workspace_id="W" * 26,
workspace_code="001",
workspace_name="001",
role="business_team",
permissions=frozenset(),
)
dependency = require_operations_permission("operations:model-overview:view")
assert await dependency(allowed) is allowed
with pytest.raises(HTTPException) as error:
await dependency(denied)
assert error.value.status_code == 403
@@ -0,0 +1,44 @@
from __future__ import annotations
import pytest
from backend.services.operations import get_workbench
class _EmptyResult:
def mappings(self):
return self
def __iter__(self):
return iter(())
class _EmptySession:
async def execute(self, *args, **kwargs):
return _EmptyResult()
@pytest.mark.asyncio
async def test_workbench_returns_empty_role_aware_shape_without_data() -> None:
data = await get_workbench(
_EmptySession(),
_EmptySession(),
"W" * 26,
workspace_code="001",
workspace_name="001",
role="business_team",
)
assert [item["label"] for item in data["kpis"]] == [
"在管模型",
"B / C 等级",
"待处理结果",
"需主动干预",
"待我反馈",
"待阅读报告",
]
assert all(item["value"] == 0 for item in data["kpis"])
assert data["alerts"] == []
assert data["todos"] == []
assert data["watches"] == []
assert data["activities"] == []
-5
View File
@@ -97,11 +97,6 @@ class Settings(BaseSettings):
default="model-platform:operations:events"
)
operations_event_stream_maxlen: int = Field(default=10000, ge=100)
operations_data_mode: str = Field(
default="database",
description="Operations data source: database or mock.",
)
# ── JWT ───────────────────────────────────────────────────────
jwt_secret: str = Field(
default="dev-only-not-for-production",
@@ -0,0 +1,151 @@
# A 卡模型运维模块后端架构与数据库设计 V0.3
> 状态:工程实现基线;首条接口链路已实现,真实非空数据联调和三角色验收待完成
> 日期:2026-09-02
> 依据:当前 `feature/a-card-operations` 代码、`origin/develop` 基础工程、现有三库结构和当前运维前端原型
## 1. 本版本结论
本版本将 V0.2 设计稿与当前代码实现对齐,作为本周开发和联调基线。
1. 同一工程内继续保留平台能力和运维能力,不拆分微服务。
2. 登录、Workspace 和平台级 RBAC 继续复用 `model_platform`
3. 模型部署、银行、模型版本的首条查询链路当前读取 `model_deploy`
4. 月度监控结果、判级和复核快照当前读取 `model_operations`
5. React 运维页面已切换为真实 API;没有数据时保留页面结构并显示 0、— 或空列表。
6. 当前已经完成接口代码、空数据验证和前端纵向调用;尚未用非空业务数据完成端到端验收。
## 2. 总体架构
```text
浏览器
│ 同源请求 /api/v1/*
Nginx / Vite 代理
FastAPI
├─ 平台上下文:model_platform
│ ├─ 登录、Cookie、用户、角色、权限、Workspace
│ └─ 平台已有脚本、对象存储和调度能力
├─ 模型来源查询:model_deploy(只读)
│ ├─ model_deploy
│ ├─ model_bank
│ ├─ model_deploy_bank_map
│ ├─ model_version
│ └─ sys_project_space
└─ 运维数据查询/写入:model_operations
├─ ops_monitor_batches
├─ ops_monitor_results
├─ ops_monitor_evaluations
├─ ops_monitor_reviews
└─ 后续报告、流程、知识库、配置和 Outbox
```
当前工作区代码目录:
- `backend/src/backend/api/operations/`:运维 API、Workspace/角色依赖和健康检查。
- `backend/src/backend/services/operations/deploy_model_queries.py`:跨 `model_deploy``model_operations` 的首条模型查询服务。
- `backend/src/backend/services/operations/workbench_queries.py`:工作台聚合查询。
- `frontend/app/services/operationsApi.ts`:运维 API 请求和 DTO 转换。
- `frontend/app/features/operations/OperationsDataContext.tsx`:统一加载真实接口数据并处理空数据。
- `frontend/app/features/operations/`:运维页面和展示组件。
## 3. 数据责任边界
### 3.1 平台库 `model_platform`
由现有平台和权限模块负责。运维模块只复用登录和 Workspace 上下文,不创建新的账号或 RBAC 表。
- 用户、角色、权限和 Workspace:现有认证/系统管理模块维护。
- `password_hash`:运维模块禁止读取。
- 运维页面的登录角色只使用统一认证返回的 `role_code`
### 3.2 模型库 `model_deploy`
首条链路作为模型来源库只读查询。模型方负责维护:
- 银行:`model_bank`
- 模型部署实例:`model_deploy`
- 模型与银行关系:`model_deploy_bank_map`
- 模型版本:`model_version`
- Workspace/项目空间映射:`sys_project_space``deploy.group_code`
### 3.3 运维库 `model_operations`
监控和运维模块维护:
- 月度批次、原始监控结果、特征指标和分布。
- 判级快照、模型团队初审、业务团队终审。
- 报告、流程、文档、Prompt、规则、配置、使用统计和 Outbox。
## 4. 当前首条真实接口链路
### 4.1 模型列表
`GET /api/v1/operations/models?workspace_id=<workspace_id>`
后端先从 `model_deploy` 查询模型、银行、版本和 Workspace 映射,再从 `model_operations` 查询最新已发布监控结果和当前判级,最后组装前端 DTO。
### 4.2 模型详情
`GET /api/v1/operations/models/{model_id}?workspace_id=<workspace_id>`
`deploy.code` 或部署 ID 识别模型,返回与模型列表相同的数据口径,避免列表和详情出现两套计算结果。
### 4.3 单月监控结果
`GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM&workspace_id=<workspace_id>`
只读取 `batch_status='published'` 的批次,并按监控月份和修订号取当前结果。月份不存在或模型没有该月结果时返回业务层的“结果不存在”,前端保持详情页面结构。
### 4.4 结果关联要求
`ops_monitor_results` 需要能与 `model_deploy` 的部署行关联。当前查询服务支持以下任一关联标识:
- `model_instance_id = model_deploy.id`
- `source_result_ref = model_deploy.code`
- `source_result_ref = model_deploy.id`
- 版本标识与 `model_version.id` 对应。
如果模型方采用其他来源 ID,需要在写入协议中明确映射,不能让前端自行猜测。
## 5. 当前 API 实现状态
已经实现:
1. `GET /api/v1/operations/health`
2. `GET /api/v1/operations/models`
3. `GET /api/v1/operations/models/{model_id}`
4. `GET /api/v1/operations/models/{model_id}/monitor-results`
5. `GET /api/v1/operations/workbench`
尚未实现的后续 API 包括模型大类汇总、银行汇总、监控明细筛选、特征分布、处理复核、报告、流程、知识库、规则、Prompt 和系统配置写接口。
## 6. 登录与 RBAC
- 登录继续使用现有 `/api/v1/auth/login` 和 Cookie 会话。
- `/api/v1/auth/me` 返回当前用户、Workspace、`role_code` 和权限集合。
- 前端将 `admin` 映射为管理员,将 `developer/model_team` 映射为模型团队,将 `business/business_team/biz` 映射为业务团队。
- 运维查询接口使用 `operations_context` 校验登录状态和 Workspace 访问范围。
- 平台用户、角色、权限接口继续使用现有 `system_admin_context`,不由运维模块重新实现。
- 前端隐藏菜单只负责展示,服务端仍必须对写接口进行授权校验。
## 7. 当前缺口与决策项
1. **表模型口径尚未完全统一**V0.2 设计过 `ops_banks/ops_model_instances/ops_model_versions` 作为模型方外部写入表,但当前首条接口实际查询的是 `model_deploy` 及其关联表。需要模型方确认最终以哪套表为主,并在确认后统一代码、DDL和文档。
2. **非空数据缺口**:当前联调库模型和监控结果为空,尚未完成非空链路验收。
3. **Workspace 映射缺口**:需要确认 `sys_project_space.name``sys_project_space.id``deploy.group_code` 与认证 Workspace 的最终映射。
4. **监控结果关联缺口**:需要确认 `model_instance_id/model_version_id/source_result_ref` 的来源 ID 规则。
5. **三角色验收缺口**:需要准备三个真实账号并完成菜单、页面、查询和写操作验收。
6. **完整 API 文档缺口**:当前接口已注册,但请求/响应/错误码尚未形成独立对外清单。
## 8. 本周验收口径
本周目标只有在以下条件全部满足后才可标记完成:
1. 架构、数据库、API 和工程骨架文档与当前代码一致。
2. 三角色可以登录,角色展示和基础访问边界正确。
3. 模型方提供最小非空数据。
4. 列表、详情、单月结果三次请求成功。
5. 三个页面的模型、版本、月份、排序性、KS、PSI 等字段一致。
6. 空数据、无结果、不存在模型、无权限和跨 Workspace 请求均有明确结果。
@@ -0,0 +1,179 @@
-- A 卡运维平台角色权限矩阵初始化 V0.1
-- 目标库:model_platform
-- 说明:
-- 1. 本脚本只新增/恢复运维权限和业务团队角色,不删除既有权限。
-- 2. 重复执行幂等;已有软删除权限/关联会被恢复。
-- 3. admin、developer 使用现有角色;developer 对应模型团队。
-- 4. 业务账号不会在本脚本中被批量改角色,需确认用户名后再分配 business_team。
USE model_platform;
SET NAMES utf8mb4;
START TRANSACTION;
-- 业务团队角色:不存在时创建,存在时恢复并更新展示信息。
INSERT INTO roles (
role_id, role_code, role_name, role_scope, is_builtin,
description, is_deleted, deleted_at
) VALUES (
'0000000000VKFJ85E90FABHPGT',
'business_team',
'业务团队',
'platform',
1,
'A 卡运维平台业务团队角色',
0,
NULL
) AS incoming_role
ON DUPLICATE KEY UPDATE
role_name = incoming_role.role_name,
role_scope = incoming_role.role_scope,
is_builtin = incoming_role.is_builtin,
description = incoming_role.description,
is_deleted = 0,
deleted_at = NULL;
-- 运维页面和操作权限。
INSERT INTO permissions (
permission_id, permission_code, permission_name, module_code,
description, is_deleted, deleted_at
) VALUES
('10000000000000000000000001', 'operations:workbench:view', '查看运维工作台', 'operations', '查看运维工作台、KPI、待办和关注', 0, NULL),
('10000000000000000000000002', 'operations:usage:view', '查看平台使用统计', 'operations', '查看平台使用统计', 0, NULL),
('10000000000000000000000003', 'operations:model-overview:view', '查看模型大类概览', 'operations', '查看模型大类概览', 0, NULL),
('10000000000000000000000004', 'operations:bank-overview:view', '查看细分银行概览', 'operations', '查看细分银行概览', 0, NULL),
('10000000000000000000000005', 'operations:deployed-models:view', '查看已上线模型详情', 'operations', '查看已上线模型详情', 0, NULL),
('10000000000000000000000006', 'operations:monitoring-overview:view', '查看模型监控概览', 'operations', '查看模型监控概览和监控明细', 0, NULL),
('10000000000000000000000007', 'operations:monitoring-detail:view', '查看模型监控详情', 'operations', '查看模型监控详情和指标分布', 0, NULL),
('10000000000000000000000008', 'operations:monitoring-detail:model-review', '模型团队初审监控结果', 'operations', '模型团队处理监控结果并提交初审', 0, NULL),
('10000000000000000000000009', 'operations:monitoring-detail:business-review', '业务团队终审监控结果', 'operations', '业务团队确认监控处理结果', 0, NULL),
('1000000000000000000000000A', 'operations:report:view', '查看监控诊断报告', 'operations', '查看监控诊断报告', 0, NULL),
('1000000000000000000000000B', 'operations:report:edit', '编辑监控诊断报告', 'operations', '编辑监控诊断报告内容', 0, NULL),
('1000000000000000000000000D', 'operations:report:export', '导出监控诊断报告', 'operations', '导出监控诊断报告', 0, NULL),
('1000000000000000000000000E', 'operations:report:send', '发送监控诊断报告', 'operations', '发送监控诊断报告', 0, NULL),
('1000000000000000000000000F', 'operations:report-summary:view', '查看历史报告汇总', 'operations', '查看历史报告汇总', 0, NULL),
('10000000000000000000000010', 'operations:workflow:view', '查看全流程进度', 'operations', '查看全流程进度', 0, NULL),
('10000000000000000000000011', 'operations:workflow:create', '发起模型需求', 'operations', '业务团队发起模型开发或迭代需求', 0, NULL),
('10000000000000000000000012', 'operations:workflow:feedback', '流程反馈', 'operations', '业务团队填写方案设计等反馈', 0, NULL),
('10000000000000000000000013', 'operations:workflow:confirm', '流程确认', 'operations', '业务团队确认流程材料和结果', 0, NULL),
('10000000000000000000000014', 'operations:workflow:submit-material', '提交流程材料', 'operations', '模型团队提交流程材料', 0, NULL),
('10000000000000000000000015', 'operations:workflow:advance', '推进流程', 'operations', '模型团队推进流程环节', 0, NULL),
('10000000000000000000000016', 'operations:workflow:online', '模型上线', 'operations', '模型团队提交模型上线信息', 0, NULL),
('10000000000000000000000017', 'operations:knowledge:view', '查看文档知识库', 'operations', '按模型、名称和环节查看文档知识库', 0, NULL),
('10000000000000000000000018', 'operations:rules:view', '查看监控等级规则', 'operations', '查看监控等级规则', 0, NULL),
('10000000000000000000000019', 'operations:rules:publish', '发布监控等级规则', 'operations', '发布监控等级规则版本', 0, NULL),
('1000000000000000000000001A', 'operations:rules:rollback', '回滚监控等级规则', 'operations', '回滚监控等级规则版本', 0, NULL),
('1000000000000000000000001B', 'operations:rules:simulate', '测算监控等级规则', 'operations', '测算阈值变化影响', 0, NULL),
('1000000000000000000000001C', 'operations:prompt:view', '查看报告 Prompt', 'operations', '查看报告 Prompt 管理页面', 0, NULL),
('1000000000000000000000001D', 'operations:prompt:edit', '编辑报告 Prompt', 'operations', '编辑报告 Prompt', 0, NULL),
('1000000000000000000000001E', 'operations:prompt:regression', '提交 Prompt 回归', 'operations', '提交 Prompt 回归测试', 0, NULL),
('1000000000000000000000001F', 'operations:settings:view', '查看系统配置', 'operations', '查看和维护运维系统配置', 0, NULL) AS incoming_permission
ON DUPLICATE KEY UPDATE
permission_name = incoming_permission.permission_name,
module_code = incoming_permission.module_code,
description = incoming_permission.description,
is_deleted = 0,
deleted_at = NULL;
-- 业务团队:我的工作台、上线/监控/报告/流程的业务侧能力。
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.role_id, p.permission_id
FROM roles AS r
JOIN permissions AS p
ON p.permission_code IN (
'dashboard:view',
'operations:workbench:view',
'operations:model-overview:view',
'operations:bank-overview:view',
'operations:monitoring-overview:view',
'operations:monitoring-detail:view',
'operations:monitoring-detail:business-review',
'operations:report:view',
'operations:report:export',
'operations:report-summary:view',
'operations:workflow:view',
'operations:workflow:create',
'operations:workflow:feedback',
'operations:workflow:confirm'
)
WHERE r.role_code = 'business_team'
AND r.is_deleted = 0
AND p.is_deleted = 0
ON DUPLICATE KEY UPDATE
is_deleted = 0,
deleted_at = NULL;
-- 模型团队:developer 角色对应模型团队。
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.role_id, p.permission_id
FROM roles AS r
JOIN permissions AS p
ON p.permission_code IN (
'dashboard:view',
'operations:workbench:view',
'operations:model-overview:view',
'operations:bank-overview:view',
'operations:deployed-models:view',
'operations:monitoring-overview:view',
'operations:monitoring-detail:view',
'operations:monitoring-detail:model-review',
'operations:report:view',
'operations:report:edit',
'operations:report:export',
'operations:report:send',
'operations:report-summary:view',
'operations:workflow:view',
'operations:workflow:submit-material',
'operations:workflow:advance',
'operations:workflow:online',
'operations:knowledge:view',
'operations:rules:view',
'operations:prompt:view',
'operations:prompt:edit',
'operations:prompt:regression'
)
WHERE r.role_code = 'developer'
AND r.is_deleted = 0
AND p.is_deleted = 0
ON DUPLICATE KEY UPDATE
is_deleted = 0,
deleted_at = NULL;
-- 管理员:拥有全部运维页面和操作权限;既有系统权限不受影响。
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.role_id, p.permission_id
FROM roles AS r
JOIN permissions AS p
ON p.module_code = 'operations'
AND p.is_deleted = 0
WHERE r.role_code = 'admin'
AND r.is_deleted = 0
ON DUPLICATE KEY UPDATE
is_deleted = 0,
deleted_at = NULL;
COMMIT;
-- 执行后核验:应看到 admin、developer、business_team 三个角色的运维权限。
SELECT
r.role_code,
r.role_name,
COUNT(p.permission_id) AS operations_permission_count
FROM roles AS r
LEFT JOIN role_permissions AS rp
ON rp.role_id = r.role_id
AND rp.is_deleted = 0
LEFT JOIN permissions AS p
ON p.permission_id = rp.permission_id
AND p.module_code = 'operations'
AND p.is_deleted = 0
WHERE r.role_code IN ('admin', 'developer', 'business_team')
GROUP BY r.role_code, r.role_name
ORDER BY FIELD(r.role_code, 'admin', 'developer', 'business_team');
-- 如需给某个业务账号分配业务团队角色,请先确认用户名,再单独执行:
-- UPDATE users
-- SET platform_role_id = (SELECT role_id FROM roles WHERE role_code = 'business_team'),
-- updated_at = CURRENT_TIMESTAMP(3)
-- WHERE username = '<确认后的业务账号>'
-- AND is_deleted = 0;
@@ -0,0 +1,165 @@
# A 卡模型运维模块:本周目标完成情况
> 版本:V0.1
> 日期:2026-09-02
> 用途:内部开发推进、联调和周目标验收基线
## 一、结论
本周目标目前属于“工程基础已具备,真实非空数据验收未完成”。
技术架构、数据库设计初稿、前后端工程骨架和首条接口代码已经形成;登录与角色适配能力已有基础;“模型列表—模型详情—单月监控结果”三条接口也已经接入前端真实请求。
当前不能直接宣称本周目标全部完成,主要原因是联调数据库中的模型、版本和监控结果目前没有非空业务数据。因此现在验证到的是接口注册、响应结构、空数据降级和前端调用链路,还没有验证真实业务数据下的完整页面展示及数值一致性。
## 二、目标逐项状态
### 1. 技术架构
状态:已完成初稿,进入实现校准阶段。
已具备:
1. 同一工程、平台库与运维库双数据库边界。
2. `model_platform` 作为现有认证、Workspace、平台基础能力来源。
3. `model_deploy` 作为模型部署、银行、版本等模型侧数据来源。
4. `model_operations` 作为监控结果和运维业务数据来源。
5. FastAPI、React、MySQL、对象存储和后续 Outbox/队列的职责边界。
6. 架构图、数据域关系图和数据库设计稿。
仍需补齐:
1. 把设计稿中的“计划实现”与当前已经实现的真实代码逐项对齐。
2. 明确模型方写入 `model_deploy``model_operations` 的最终字段及批次协议。
3. 补充部署环境的连接配置、账号权限和回滚方式。
### 2. 数据库模型初稿
状态:DDL 和表关系初稿已完成,等待 DBA/模型方确认和非空数据验证。
已具备:
1. `model_operations` 建库建表脚本及校验脚本。
2. 模型、版本、监控批次、监控结果、特征指标、分布、判级、复核、报告、流程和 Outbox 等表的初稿。
3. 页面—表—数据责任关系说明。
4. 模型方外部写入表的范围说明。
仍需补齐:
1. 确认 `model_deploy``model_operations` 的实际表结构与字段类型。
2. 确认银行表最终来源;当前银行主数据仍待模型方补充确认。
3. DBA 执行建库/授权,或确认现有表已满足接口查询。
4. 由模型方提供一组可追溯的非空测试数据。
5. 对模型、版本、监控月份、修订号和发布状态补充唯一性与幂等约束。
### 3. API 清单
状态:首条链路接口已实现,完整 API 契约文档尚未完成。
当前已注册并可从 OpenAPI 查到:
1. `GET /api/v1/operations/health`
2. `GET /api/v1/operations/models`
3. `GET /api/v1/operations/models/{model_id}`
4. `GET /api/v1/operations/models/{model_id}/monitor-results?month=YYYY-MM`
5. `GET /api/v1/operations/workbench`
仍需补齐:
1. 将运维接口从通用 `API.md` 中拆出独立清单。
2. 补充每个接口的请求参数、响应字段、状态码、错误码和权限要求。
3. 明确百分比单位、月份格式、空值规则和分页规则。
4. 增加模型大类、银行概览、监控明细、报告、流程、知识库和配置接口清单。
### 4. 前后端工程骨架
状态:已完成首期骨架。
已具备:
1. FastAPI 运维路由、依赖、服务和查询层目录。
2. React 运维路由、数据上下文、API service 和页面骨架。
3. 前端运维模块已关闭生产 Mock 数据,统一请求后端接口。
4. 缺少真实数据时,页面保留结构并显示 0、— 或空列表。
5. 5173 当前功能分支和 5174 `develop` 对照环境均可独立启动。
仍需补齐:
1. 其余页面从占位/空状态逐步接入后端资源接口。
2. 报告、流程、处理、导出和配置等写操作接口。
3. 前端统一处理接口错误、权限不足、空数据和加载状态。
4. CI 中固定执行前端 typecheck/build 和后端测试。
### 5. 三角色登录与 RBAC 基础能力
状态:登录和角色适配已有,三角色真实账号验收未完成。
已具备:
1. 继续复用现有登录接口和 Cookie 会话。
2. 登录返回 `role_code`、用户信息、Workspace 和权限集合。
3. 前端可区分业务团队、模型团队和管理员。
4. 系统管理的用户、角色、项目等既有功能继续复用。
5. 运维 API 使用 Workspace 访问校验和角色映射。
仍需补齐:
1. 分别使用业务团队、模型团队、管理员账号完成登录验收。
2. 验证三种角色的菜单可见范围、页面可见范围和按钮可见范围。
3. 验证后端不能仅依赖前端隐藏按钮,而是对写接口再次进行角色校验。
4. 明确运维模块与既有权限模块的最终责任边界。
### 6. 首条真实接口纵向链路
状态:代码链路已跑通,真实非空业务链路未验收。
目标链路:
`model_deploy / model_operations` 数据 → 后端模型查询 → 模型列表 → 模型详情 → 单月监控结果 → 前端详情展示。
已验证:
1. 三个接口已注册。
2. 前端已从 Mock 切换为真实 API 请求。
3. 缺表时后端可降级为空数据,页面不再跳转到说明页。
4. 空库时列表、工作台和相关页面能够保留结构并显示空状态。
5. 前端类型检查和生产构建通过。
未完成:
1. 模型方写入至少 1 个银行、1 个模型、1 个版本和 1 个月监控结果。
2. 使用登录会话从列表点击进入详情。
3. 从详情进入指定月份监控结果。
4. 核对列表、详情和监控结果中的模型名称、版本、KS、PSI、排序性和处理状态一致。
5. 验证不同 Workspace、无权限和不存在模型的返回结果。
## 三、当前最关键的三个缺口
1. **没有非空真实数据**:这是首条纵向链路还不能验收的首要原因。
2. **三角色没有完成真实账号矩阵验收**:现有代码具备基础,但还没有形成可签字的验收记录。
3. **设计文档与实现状态未同步**:现有架构 V0.2 仍写着“接口尚未实现”,需要新版本勘误。
## 四、现在可以先写的文档
以下内容不依赖全部业务功能完成,可以立即起草:
1. **技术架构与实现说明 V0.3**:以当前双数据库、FastAPI、React 和真实接口代码为准,标出已实现/待实现边界。
2. **数据库模型与外部写入协议 V0.2**:列出模型方需要写入的表、字段、写入时机、批次状态、幂等和发布规则。
3. **运维 API 清单 V0.1**:先固化已实现的 5 个接口,再把后续接口标为规划项。
4. **三角色登录/RBAC 验收说明 V0.1**:列出角色、菜单、页面、接口和写操作验收项。
5. **首条纵向链路联调手册 V0.1**:写清测试数据准备、请求顺序、预期响应和数值核对点。
6. **本周目标验收记录 V0.1**:等模型方提供非空数据后,直接补充实际请求和截图结果。
## 五、建议本周剩余顺序
1. 先让模型方补一套最小非空数据,不等待全部页面完成。
2. 先完成列表—详情—单月结果的真实联调和数值核对。
3. 同时用三种角色做登录、菜单和接口权限验收。
4. 根据联调结果更新架构 V0.3、数据库外部写入协议 V0.2 和 API 清单 V0.1。
5. 最后再扩展工作台、监控明细、报告和流程等页面的接口。
## 六、当前不可对外承诺的内容
在非空数据和三角色验收完成前,不建议对外表述为“首条真实接口链路已完整跑通”。更准确的表述是:
> 已完成首条真实接口链路的工程实现、接口注册、前端接入和空数据验证;待模型方提供最小非空数据后完成业务数据联调验收。
+2 -3
View File
@@ -1,6 +1,5 @@
# Model-platform gateway used by the Vite development proxy.
VITE_GATEWAY_URL=http://127.0.0.1:8890
# "mock" keeps the A-card operations pages on local frontend fixtures.
# Switch to "api" when /api/v1/operations endpoints are available.
VITE_OPERATIONS_API_MODE=mock
# 运维页面只允许通过后端真实接口取数。
VITE_OPERATIONS_API_MODE=api
+1 -1
View File
@@ -19,7 +19,7 @@ pnpm install
pnpm dev
```
运维模块默认支持前端 Mock;当前本地 `frontend/.env` 已切换为后端 API
运维模块只通过后端真实 API 取数;当前本地 `frontend/.env` 配置为
```bash
VITE_OPERATIONS_API_MODE=api
@@ -13,6 +13,7 @@ type TopbarProps = {
onSetWorkspaceMenuOpen: (open: boolean) => void;
onSetCurrentWorkspace: (workspaceId: string) => void;
onLogout: () => void;
onBack: () => void;
};
export function Topbar({
@@ -25,6 +26,7 @@ export function Topbar({
onSetWorkspaceMenuOpen,
onSetCurrentWorkspace,
onLogout,
onBack,
}: TopbarProps) {
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -57,6 +59,8 @@ export function Topbar({
<button
className="grid h-[31px] w-[31px] rotate-180 cursor-pointer place-items-center rounded-[7px] border border-transparent bg-white text-[#7b8999] hover:border-[#b9c9da] hover:bg-[#f7faff]"
type="button"
aria-label="返回上一页"
onClick={onBack}
>
<ChevronRight size={19} className="text-[#748497]"/>
</button>
@@ -15,6 +15,7 @@ const MODULE_LABELS: Record<string, string> = {
script: "构建脚本",
schedule: "调度配置",
system: "系统管理",
operations: "运维工作台",
};
/** 勾选框样式:复刻原生 checkbox 外观(白底、灰边、蓝色勾选),并对齐 permission item 的 3px 顶部偏移。 */
@@ -18,7 +18,7 @@ export type UserFormState = {
username: string;
display_name: string;
email: string;
role_code: "admin" | "developer";
role_code: string;
password: string;
status: "active" | "disabled" | "locked";
};
@@ -1,7 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
@@ -37,13 +36,12 @@ type BankCategoryCard = {
modelCount: number;
versions: number;
latestIteration: string;
averageCycle: number;
averageCycle: number | null;
ownKs: number;
ownPsi: number;
peerKs: number;
peerPsi: number;
grades: Record<ModelGrade, number>;
demo?: boolean;
};
function isCategoryId(value: string | null): value is ModelCategoryId {
@@ -114,14 +112,13 @@ export default function BankOverviewPage() {
const grades = bankModels.reduce<Record<ModelGrade, number>>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 });
return { ...item, models: bankModels, modelCount: bankModels.length, versions: new Set(bankModels.map((model) => model.version)).size, latestIteration: latestIterationDate(bankModels), averageCycle: averageIterationCycle(bankModels), ownKs: average(bankModels.map((model) => model.ks)), ownPsi: average(bankModels.map((model) => model.psi)), peerKs: average(allPeers.map((model) => model.ks)), peerPsi: average(allPeers.map((model) => model.psi)), grades };
});
return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], modelCount: 1, versions: 1, latestIteration: "2026-07-18", averageCycle: 11, ownKs: 37.9, ownPsi: 16.6, peerKs: 39.6, peerPsi: 14.2, grades: { A: 0, B: 1, C: 0 }, demo: true }];
return realCards;
}, [bank, models]);
const syncParams = (nextBank: string, nextCategory: ModelCategoryId) => setSearchParams({ bank: nextBank, category: nextCategory });
const selectBank = (value: string) => { setBank(value); syncParams(value, category); };
const selectCategory = (value: ModelCategoryId, scroll = false) => { setCategory(value); syncParams(bank, value); if (scroll) requestAnimationFrame(() => trendRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); };
const openGrade = (item: BankCategoryCard, grade: ModelGrade) => {
if (item.demo) return toast.info("该卡片仅用于展示细分银行第 5 个大类的纵向滚动效果");
const gradeModels = item.models.filter((model) => gradeOf(model) === grade);
if (gradeModels.length === 1) return navigate(`/operations/monitoring/${gradeModels[0]?.modelId}`);
setDrilldown({ category: item.id as ModelCategoryId, grade, models: gradeModels });
@@ -129,7 +126,7 @@ export default function BankOverviewPage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="细分银行概览" description="按银行和模型大类查看本行表现,并与同业均值对照。" actions={<Button variant="outline" onClick={() => navigate("/operations/deployed-models")}>线 <ArrowRight /></Button>} />
<Card size="sm"><CardContent className="flex items-end gap-4"><FilterSelect className="w-56" label="筛选银行" value={bank} allLabel="请选择银行" options={banks} onChange={selectBank} /><p className="pb-2 text-sm text-muted-foreground"></p></CardContent></Card>
@@ -137,12 +134,12 @@ export default function BankOverviewPage() {
<CategoryCardRail>
{cards.map((item) => {
const total = item.grades.A + item.grades.B + item.grades.C || 1;
const selected = !item.demo && item.id === category;
const selected = item.id === category;
const hasModels = item.modelCount > 0;
const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同结构") : selectCategory(item.id as ModelCategoryId, true);
return <Card className={`cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-1 hover:ring-primary/40 ${selected ? "ring-2 ring-primary" : ""}`} key={item.id} role="button" size="sm" tabIndex={0} onClick={activate} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") activate(); }}><CardHeader><CardTitle className="flex items-center gap-2">{item.name}<span className="rounded-lg bg-muted px-2 py-1 text-2xs font-medium text-muted-foreground">{item.demo ? "扩展示意" : `银行-${bank}`}</span></CardTitle></CardHeader><CardContent className="space-y-3">
const activate = () => selectCategory(item.id as ModelCategoryId, true);
return <Card className={`cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-1 hover:ring-primary/40 ${selected ? "ring-2 ring-primary" : ""}`} key={item.id} role="button" size="sm" tabIndex={0} onClick={activate} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") activate(); }}><CardHeader><CardTitle className="flex items-center gap-2">{item.name}<span className="rounded-lg bg-muted px-2 py-1 text-2xs font-medium text-muted-foreground">-{bank || "全部"}</span></CardTitle></CardHeader><CardContent className="space-y-3">
<dl className="grid grid-cols-3 gap-3">{[["银行数", hasModels ? 1 : 0], ["模型数", item.modelCount], ["版本数", item.versions]].map(([label, value]) => <div key={label}><dt className="whitespace-nowrap text-2xs text-ink-caption">{label}</dt><dd className="mt-1 text-lg font-bold tabular-nums">{value}</dd></div>)}</dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-sm font-bold tabular-nums">{item.latestIteration}</dd></div><div><dt className="whitespace-nowrap text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-lg font-bold tabular-nums">{hasModels ? `${item.averageCycle.toFixed(1)}` : "—"}</dd></div></dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-sm font-bold tabular-nums">{item.latestIteration}</dd></div><div><dt className="whitespace-nowrap text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-lg font-bold tabular-nums">{hasModels && item.averageCycle !== null ? `${item.averageCycle.toFixed(1)}` : "—"}</dd></div></dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"> KS</dt><dd className="mt-1 text-xl font-bold tabular-nums">{hasModels ? `${item.ownKs.toFixed(1)}%` : "—"}</dd></div><div><dt className="text-2xs text-ink-caption"> PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums">{hasModels ? `${item.ownPsi.toFixed(1)}%` : "—"}</dd></div><div><dt className="text-2xs text-warning"> KS</dt><dd className="mt-1 text-xl font-bold tabular-nums text-warning">{item.peerKs.toFixed(1)}%</dd></div><div><dt className="text-2xs text-warning"> PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums text-warning">{item.peerPsi.toFixed(1)}%</dd></div></dl>
<div className="flex h-2 overflow-hidden rounded-full bg-muted"><i className="bg-success" style={{ width: `${item.grades.A / total * 100}%` }} /><i className="bg-warning" style={{ width: `${item.grades.B / total * 100}%` }} /><i className="bg-danger" style={{ width: `${item.grades.C / total * 100}%` }} /></div>
<div className="flex flex-wrap gap-2" onClick={(event) => event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => <GradeBadge grade={grade} key={grade} suffix={`${item.grades[grade]} 个模型`} onClick={() => openGrade(item, grade)} />)}</div>
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, FileSpreadsheet, FolderOpen, Upload } from "lucide-react";
import { ArrowRight, Download, FileSpreadsheet, Upload } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
@@ -10,7 +10,7 @@ import { usePersistentState } from "~/hooks/use-persistent-state";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { FilterSelect, OperationsPageHeader, StatusBadge } from "./OperationsUi";
import { useOperationsData } from "./OperationsDataContext";
import { MODEL_LIFECYCLE, type ModelRecord } from "./modelData";
import type { ModelRecord } from "./modelData";
type Filters = {
bank: string;
@@ -23,11 +23,6 @@ function unique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right, "zh-CN"));
}
function metricSeries(model: ModelRecord, base: readonly number[], variance: number): number[] {
const seed = [...model.modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0);
return base.map((value, index) => Number((value * (1 + ((seed >> index) % 7 - 3) / variance)).toFixed(2)));
}
function BarList({
values,
labels,
@@ -43,13 +38,13 @@ function BarList({
const colorClass = tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : "bg-brand";
return (
<div className="space-y-3">
{values.map((value, index) => (
{values.length ? values.map((value, index) => (
<div className="grid grid-cols-[5rem_minmax(0,1fr)_3.5rem] items-center gap-3" key={labels[index]}>
<span className="text-xs text-muted-foreground">{labels[index]}</span>
<span className="h-2.5 overflow-hidden rounded-full bg-muted"><i className={`block h-full rounded-full ${colorClass}`} style={{ width: `${value / max * 100}%` }} /></span>
<strong className="text-right text-xs tabular-nums text-foreground">{value.toFixed(2)}{suffix}</strong>
</div>
))}
)) : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground"></div>}
</div>
);
}
@@ -69,11 +64,10 @@ export default function DeployedModelsPage() {
&& (!filters.modelType || (model.commonModel ? "通用模型" : "个性化模型") === filters.modelType)
));
const model = filtered.find((item) => item.modelId === selectedModelId) ?? filtered[0] ?? null;
const lifecycle = model ? MODEL_LIFECYCLE[model.modelId] : null;
const scoreLabels = ["低分段", "中低分", "中分段", "中高分", "高分段", "最高分"];
const rankingRates = model ? metricSeries(model, [9.8, 7.2, 5.1, 3.4, 2.0, 1.1], 28) : [];
const liftValues = model ? metricSeries(model, [3.2, 2.4, 1.7, 1.1, 0.7, 0.4], 20) : [];
const psiValues = model ? metricSeries(model, [1.2, 0.9, 0.6, 0.5, 0.4, 0.3], 20) : [];
const rankingRates: number[] = [];
const liftValues: number[] = [];
const psiValues: number[] = [];
const scoreFile = model ? scoreFiles[model.modelId] : undefined;
const update = <K extends keyof Filters>(key: K, value: Filters[K]) => {
@@ -96,18 +90,13 @@ export default function DeployedModelsPage() {
};
const downloadScoreFile = () => {
if (!model) return;
void exportRowsToExcel({
fileName: scoreFile?.name.replace(/\.xlsx?$/i, "") ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑`,
sheetName: "评分逻辑",
headers: ["入模特征", "划分区间", "对应评分"],
rows: [["age", "[18,25)", 12], ["age", "[25,35)", 26], ["income", "[0,5000)", 8], ["income", "[5000,+)", 31], ["query_3m", "[0,2]", 22], ["query_3m", "[3,+)", 6]],
});
if (!model || !scoreFile) return;
toast.info("评分逻辑文件下载接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title="已上线模型详情"
description="查看已上线模型的生命周期、开发时点指标、评分逻辑和开发材料链接。"
@@ -136,7 +125,7 @@ export default function DeployedModelsPage() {
</CardContent>
</Card>
{model && lifecycle ? (
{model ? (
<>
<Card>
<CardHeader className="border-b border-border">
@@ -148,12 +137,12 @@ export default function DeployedModelsPage() {
{[
["银行 × 模型ID", `${model.bank} × ${model.modelId}`],
["版本", model.version],
["开发人员", lifecycle.developer],
["上线日期", lifecycle.onlineAt ?? "—"],
["开发人员", "—"],
["上线日期", "—"],
["最近迭代", model.iteratedAt],
["陪跑开始", lifecycle.escortStartAt ?? "—"],
["陪跑结束", lifecycle.escortEndAt ?? "—"],
["下线日期", lifecycle.offlineAt ?? "—"],
["陪跑开始", "—"],
["陪跑结束", "—"],
["下线日期", "—"],
["状态", model.status],
].map(([label, value]) => (
<div className="min-w-0 rounded-xl bg-muted/50 p-3" key={label}><span className="block whitespace-nowrap text-2xs text-muted-foreground">{label}</span><strong className="mt-1 block truncate text-sm font-semibold tabular-nums text-foreground" title={value}>{value}</strong></div>
@@ -167,11 +156,11 @@ export default function DeployedModelsPage() {
<CardContent><BarList values={rankingRates} labels={scoreLabels} tone="warning" /><p className="mt-4 rounded-xl bg-success-soft p-3 text-xs text-success-strong"></p></CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle> · KS LIFT</CardTitle><CardDescription>KS {lifecycle.developmentKs.toFixed(1)}% · LIFT {lifecycle.maxLift.toFixed(2)}</CardDescription></CardHeader>
<CardHeader><CardTitle> · KS LIFT</CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent><BarList values={liftValues} labels={scoreLabels} suffix="" tone="brand" /></CardContent>
</Card>
<Card size="sm">
<CardHeader><CardTitle> · PSI</CardTitle><CardDescription> OOT · PSI {lifecycle.developmentPsi.toFixed(2)}%</CardDescription></CardHeader>
<CardHeader><CardTitle> · PSI</CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent><BarList values={psiValues} labels={scoreLabels} tone="success" /><p className="mt-4 text-xs text-muted-foreground">线 PSI </p></CardContent>
</Card>
</div>
@@ -182,7 +171,7 @@ export default function DeployedModelsPage() {
<CardContent className="space-y-4">
<div className="flex items-center gap-3 rounded-xl border border-border p-4">
<span className="grid size-10 place-items-center rounded-xl bg-success-soft text-success-strong"><FileSpreadsheet className="size-5" /></span>
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{scoreFile?.name ?? `${model.bank}_${model.modelId}_${model.version}_评分逻辑.xlsx`}</b><small className="text-xs text-muted-foreground">{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : `模型团队 ${lifecycle.developer} · ${lifecycle.onlineAt ?? model.iteratedAt}`}</small></span>
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{scoreFile?.name ?? "暂无评分逻辑文件"}</b><small className="text-xs text-muted-foreground">{scoreFile ? `本地待上传 · ${(scoreFile.size / 1024).toFixed(1)} KB · ${scoreFile.updatedAt}` : "未关联评分逻辑文件"}</small></span>
<Button variant="outline" size="sm" onClick={downloadScoreFile}><Download /></Button>
</div>
<input ref={scoreInputRef} className="hidden" type="file" accept=".xlsx,.xls" onChange={(event) => { uploadScoreFile(event.target.files?.[0]); event.target.value = ""; }} />
@@ -196,9 +185,7 @@ export default function DeployedModelsPage() {
<CardContent className="px-0">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead className="text-right"></TableHead></TableRow></TableHeader>
<TableBody>{[
["模型设计方案", "方案设计"], ["开发结果材料", "开发迭代"], ["新老模型对比", "模型验证"], ["评审会议纪要", "评审决议"], ["一致性报告", "测试陪跑"],
].map(([name, stage]) => <TableRow key={name}><TableCell className="font-medium text-foreground"><span className="flex items-center gap-2"><FolderOpen className="size-4 text-primary" />{name}</span></TableCell><TableCell>{stage}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => navigate(`/operations/knowledge?modelId=${encodeURIComponent(model.modelId)}&stage=${encodeURIComponent(stage)}`)}></Button></TableCell></TableRow>)}</TableBody>
<TableBody><TableRow><TableCell colSpan={3} className="h-28 text-center text-muted-foreground"></TableCell></TableRow></TableBody>
</Table>
</CardContent>
</Card>
@@ -48,7 +48,7 @@ export default function KnowledgeBasePage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title="文档知识库"
description="汇总开发、迭代、评审、测试和部署各环节材料,按模型、名称和环节检索。"
@@ -1,7 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { ArrowRight, Download, TrendingDown, TrendingUp } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
@@ -17,6 +16,7 @@ import {
MONITOR_MONTHS,
abnormalLevelOf,
average,
averageIterationCycle,
categoryName,
categoryTrend,
gradeOf,
@@ -28,8 +28,6 @@ import {
type ModelRecord,
} from "./modelData";
const CATEGORY_AVERAGE_CYCLE: Record<ModelCategoryId, number> = { std: 9.3, bai: 12, big: 8, afd: 14 };
type CategoryCardItem = {
id: string;
name: string;
@@ -37,11 +35,10 @@ type CategoryCardItem = {
banks: number;
versions: number;
latestIteration: string;
averageCycle: number;
averageCycle: number | null;
averageKs: number;
averagePsi: number;
gradeCounts: Record<ModelGrade, number>;
demo?: boolean;
};
function isCategoryId(value: string | null): value is ModelCategoryId {
@@ -105,9 +102,9 @@ export default function ModelOverviewPage() {
const realCards = MODEL_CATEGORIES.map((item) => {
const categoryModels = models.filter((model) => model.category === item.id && model.status !== "下线");
const gradeCounts = categoryModels.reduce<Record<ModelGrade, number>>((result, model) => ({ ...result, [gradeOf(model)]: result[gradeOf(model)] + 1 }), { A: 0, B: 0, C: 0 });
return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: CATEGORY_AVERAGE_CYCLE[item.id], averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts };
return { ...item, models: categoryModels, banks: new Set(categoryModels.map((model) => model.bank)).size, versions: new Set(categoryModels.map((model) => model.version)).size, latestIteration: latestIterationDate(categoryModels), averageCycle: averageIterationCycle(categoryModels), averageKs: average(categoryModels.map((model) => model.ks)), averagePsi: average(categoryModels.map((model) => model.psi)), gradeCounts };
});
return [...realCards, { id: "consumer-demo", name: "消费贷评分(示意)", models: [], banks: 5, versions: 4, latestIteration: "2026-07-18", averageCycle: 10.6, averageKs: 39.6, averagePsi: 14.2, gradeCounts: { A: 2, B: 2, C: 1 }, demo: true }];
return realCards;
}, [models]);
const selectCategory = (value: ModelCategoryId, scroll = false) => {
@@ -117,26 +114,25 @@ export default function ModelOverviewPage() {
};
const openGrade = (item: CategoryCardItem, grade: ModelGrade) => {
if (item.demo) return toast.info("该卡片仅用于展示第 5 个及以上模型大类的纵向滚动效果");
setDrilldown({ category: item.id as ModelCategoryId, grade, models: item.models.filter((model) => gradeOf(model) === grade) });
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="模型大类概览" description="按模型大类汇总全部银行;点击大类卡片可定位到对应指标趋势。" actions={<Button variant="outline" onClick={() => navigate("/operations/monitoring")}> <ArrowRight /></Button>} />
<CategoryCardRail>
{categoryCards.map((item) => {
const total = item.gradeCounts.A + item.gradeCounts.B + item.gradeCounts.C || 1;
const selected = !item.demo && item.id === category;
const activate = () => item.demo ? toast.info("扩展示意卡:正式接入第 5 个大类后沿用相同卡片结构") : selectCategory(item.id as ModelCategoryId, true);
const selected = item.id === category;
const activate = () => selectCategory(item.id as ModelCategoryId, true);
return (
<Card className={`cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-1 hover:ring-primary/40 ${selected ? "ring-2 ring-primary" : ""}`} key={item.id} role="button" size="sm" tabIndex={0} onClick={activate} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") activate(); }}>
<CardHeader><CardTitle className="flex items-center gap-2">{item.name}<span className="rounded-lg bg-muted px-2 py-1 text-2xs font-medium text-muted-foreground">{item.demo ? "扩展示意" : "全部银行"}</span></CardTitle></CardHeader>
<CardHeader><CardTitle className="flex items-center gap-2">{item.name}<span className="rounded-lg bg-muted px-2 py-1 text-2xs font-medium text-muted-foreground"></span></CardTitle></CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-3 gap-3">{[["银行数", item.banks], ["模型数", item.demo ? 5 : item.models.length], ["版本数", item.versions]].map(([label, value]) => <div key={label}><dt className="whitespace-nowrap text-2xs text-ink-caption">{label}</dt><dd className="mt-1 text-lg font-bold tabular-nums">{value}</dd></div>)}</dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-sm font-bold tabular-nums">{item.latestIteration}</dd></div><div><dt className="whitespace-nowrap text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-lg font-bold tabular-nums">{item.averageCycle.toFixed(1)}</dd></div></dl>
<dl className="grid grid-cols-3 gap-3">{[["银行数", item.banks], ["模型数", item.models.length], ["版本数", item.versions]].map(([label, value]) => <div key={label}><dt className="whitespace-nowrap text-2xs text-ink-caption">{label}</dt><dd className="mt-1 text-lg font-bold tabular-nums">{value}</dd></div>)}</dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-sm font-bold tabular-nums">{item.latestIteration}</dd></div><div><dt className="whitespace-nowrap text-2xs text-ink-caption"></dt><dd className="mt-1 whitespace-nowrap text-lg font-bold tabular-nums">{item.averageCycle === null ? "—" : `${item.averageCycle.toFixed(1)}`}</dd></div></dl>
<dl className="grid grid-cols-2 gap-3"><div><dt className="text-2xs text-ink-caption"> KS</dt><dd className="mt-1 text-xl font-bold tabular-nums">{item.averageKs.toFixed(1)}%</dd></div><div><dt className="text-2xs text-ink-caption"> PSI</dt><dd className="mt-1 text-xl font-bold tabular-nums">{item.averagePsi.toFixed(1)}%</dd></div></dl>
<div className="flex h-2 overflow-hidden rounded-full bg-muted" aria-label="模型监控结果等级分布"><i className="bg-success" style={{ width: `${item.gradeCounts.A / total * 100}%` }} /><i className="bg-warning" style={{ width: `${item.gradeCounts.B / total * 100}%` }} /><i className="bg-danger" style={{ width: `${item.gradeCounts.C / total * 100}%` }} /></div>
<div className="flex flex-wrap gap-2" onClick={(event) => event.stopPropagation()}>{(["A", "B", "C"] as ModelGrade[]).map((grade) => <GradeBadge grade={grade} key={grade} suffix={`${item.gradeCounts[grade]} 个模型`} onClick={() => openGrade(item, grade)} />)}</div>
@@ -27,9 +27,8 @@ import {
modelTrend,
monthsBetween,
} from "./modelData";
import { REPORTS } from "./reportData";
import { useOperationsData, useOperationsModelDetail } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
import { useCanOperationsAction } from "./operationsRole";
function recentMonths(count: number): string[] {
const end = 2026 * 12 + 6;
@@ -43,7 +42,6 @@ export default function MonitoringDetailPage() {
const navigate = useNavigate();
const params = useParams();
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
const modelId = params.modelId ?? models[0]?.modelId ?? "";
const detail = useOperationsModelDetail(modelId, "2026-07");
const baseModel = detail.model ?? models.find((item) => item.modelId === modelId) ?? models[0];
@@ -57,6 +55,7 @@ export default function MonitoringDetailPage() {
const [reviewDecision, setReviewDecision] = useState("暂不处理");
const [reviewNote, setReviewNote] = useState("");
const months = range === "custom" ? monthsBetween(fromMonth, toMonth) : recentMonths(Number(range));
const monitorMonth = detail.monitoringResult?.monitorMonth ?? "";
const ksTrend = modelTrend(model, "ks", months);
const psiTrend = modelTrend(model, "psi", months);
const compareModel = models.find((item) => item.modelId === compareId && item.modelId !== model.modelId) ?? null;
@@ -64,12 +63,11 @@ export default function MonitoringDetailPage() {
const comparePsiTrend = compareModel ? modelTrend(compareModel, "psi", months) : null;
const grade = gradeOf(model);
const abnormal = abnormalLevelOf(model);
const linkedReport = REPORTS.find((report) => report.modelId === model.modelId && report.monitorMonth === "2026-07") ?? null;
const chosenFeature = FEATURE_METRICS.find((item) => item.key === selectedFeature) ?? null;
const ivTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.ivDrop - left.ivDrop), []);
const csiTop = useMemo(() => [...FEATURE_METRICS].sort((left, right) => right.csiRise - left.csiRise), []);
const canInitialReview = isOperationsActionVisibleForRole(operationsRole, "monitor:initial-review");
const canFinalReview = isOperationsActionVisibleForRole(operationsRole, "monitor:final-review");
const canInitialReview = useCanOperationsAction("monitor:initial-review");
const canFinalReview = useCanOperationsAction("monitor:final-review");
const effectiveReviewStage = grade === "A" ? 2 : reviewStage;
const canReview = grade !== "A" && ((reviewStage === 0 && canInitialReview) || (reviewStage === 1 && canFinalReview));
@@ -97,7 +95,7 @@ export default function MonitoringDetailPage() {
}, [modelId]);
if (detail.loading) {
return <section className="h-full overflow-auto bg-bg p-6"><div className="mx-auto max-w-screen-2xl space-y-6"><Skeleton className="h-20 w-full rounded-4xl" /><Skeleton className="h-96 w-full rounded-4xl" /><div className="grid grid-cols-2 gap-6"><Skeleton className="h-72 rounded-4xl" /><Skeleton className="h-72 rounded-4xl" /></div></div></section>;
return <section className="h-full overflow-auto bg-bg p-6"><div className="w-full space-y-6"><Skeleton className="h-20 w-full rounded-4xl" /><Skeleton className="h-96 w-full rounded-4xl" /><div className="grid grid-cols-2 gap-6"><Skeleton className="h-72 rounded-4xl" /><Skeleton className="h-72 rounded-4xl" /></div></div></section>;
}
if (detail.error) {
@@ -106,7 +104,7 @@ export default function MonitoringDetailPage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title="模型监控详情"
description={`${model.bank} · ${model.name} ${model.version} · ${model.modelId}`}
@@ -155,7 +153,7 @@ export default function MonitoringDetailPage() {
<Card>
<CardHeader className="border-b border-border">
<CardTitle> </CardTitle>
<CardDescription>2026-07 · </CardDescription>
<CardDescription>{monitorMonth ? `${monitorMonth} 监控周期 · 结果优先展示` : "暂无监控结果"}</CardDescription>
<CardAction className="flex items-center gap-2">
<StatusBadge status={model.status} />
<GradeBadge grade={grade} suffix="等级" />
@@ -213,8 +211,8 @@ export default function MonitoringDetailPage() {
<FileText className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="flex-1">
<b className="text-sm text-foreground"></b>
<p className="mt-1 text-sm text-muted-foreground">{grade === "A" ? "模型监控报告" : "模型诊断报告"} · 2026-07 · 2026-08-15</p>
<Button className="mt-2 px-0" variant="link" size="sm" onClick={() => navigate(linkedReport ? `/operations/reports?report=${linkedReport.reportId}` : "/operations/reports")}> <ArrowRight /></Button>
<p className="mt-1 text-sm text-muted-foreground">{monitorMonth ? `${grade === "A" ? "模型监控报告" : "模型诊断报告"} · ${monitorMonth}` : "暂无关联报告"}</p>
<Button className="mt-2 px-0" variant="link" size="sm" onClick={() => navigate("/operations/reports")}> <ArrowRight /></Button>
</div>
</div>
</div>
@@ -239,7 +237,7 @@ export default function MonitoringDetailPage() {
</div>
<Card size="sm">
<CardHeader><CardTitle></CardTitle><CardDescription>2026-07 · 线</CardDescription></CardHeader>
<CardHeader><CardTitle></CardTitle><CardDescription>{monitorMonth || "当期"} · 线</CardDescription></CardHeader>
<CardContent><SortingComboChart {...SORTING_DISTRIBUTION} /></CardContent>
</Card>
@@ -312,22 +310,9 @@ export default function MonitoringDetailPage() {
<CardContent className="border-t border-border">
<div className="mb-4 flex items-start gap-3 rounded-xl bg-brand-soft p-4 text-sm text-primary">
<Info className="mt-0.5 size-5 shrink-0" />
<div><b>{chosenFeature.name}{chosenFeature.key}</b><p className="mt-1 text-primary/80"> 2026-07 </p></div>
</div>
<div className="grid grid-cols-5 gap-4">
{[38, 27, 18, 11, 6].map((reference, index) => {
const current = Math.max(2, reference + [4, -3, 2, -1, -2][index]);
return (
<div className="rounded-xl border border-border p-4" key={reference}>
<span className="text-xs text-muted-foreground"> {index + 1}</span>
<div className="mt-3 space-y-2">
<div><span className="flex justify-between text-xs"><i></i><b>{reference}%</b></span><span className="mt-1 block h-2 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-ink-subtle" style={{ width: `${reference * 2}%` }} /></span></div>
<div><span className="flex justify-between text-xs"><i></i><b>{current}%</b></span><span className="mt-1 block h-2 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-primary" style={{ width: `${current * 2}%` }} /></span></div>
</div>
</div>
);
})}
<div><b>{chosenFeature.name}{chosenFeature.key}</b><p className="mt-1 text-primary/80"></p></div>
</div>
<div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground"></div>
</CardContent>
)}
</Card>
@@ -179,7 +179,7 @@ export default function MonitoringOverviewPage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="模型监控概览" description="查看监控结果等级分布与按月展开的监控明细,默认展示最新月份的 B / C 等级模型。" actions={<Button variant="outline" onClick={() => void exportMonitoringExcel(sortedRows)}><Download /> Excel</Button>} />
<div className="grid grid-cols-3 gap-4">
@@ -1,5 +1,5 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Database, RefreshCw, TriangleAlert } from "lucide-react";
import { RefreshCw, TriangleAlert } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
@@ -8,7 +8,10 @@ import { useAuth } from "~/context/AuthContext";
import {
getMonthlyMonitoringResult,
getOperationsModel,
getOperationsWorkbench,
listOperationsModels,
OperationsApiError,
type OperationsWorkbenchDto,
operationsApiMode,
type OperationsApiMode,
} from "~/services/operationsApi";
@@ -16,6 +19,7 @@ import type { ModelRecord, MonitoringRow } from "./modelData";
type OperationsDataContextValue = {
models: ModelRecord[];
workbench: OperationsWorkbenchDto;
loading: boolean;
error: string | null;
source: OperationsApiMode;
@@ -24,10 +28,20 @@ type OperationsDataContextValue = {
const OperationsDataContext = createContext<OperationsDataContextValue | null>(null);
const EMPTY_WORKBENCH: OperationsWorkbenchDto = {
role: "model_team",
alerts: [],
kpis: [],
todos: [],
watches: [],
activities: [],
};
export function OperationsDataProvider({ children }: { children: ReactNode }) {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id;
const [models, setModels] = useState<ModelRecord[]>([]);
const [workbench, setWorkbench] = useState<OperationsWorkbenchDto>(EMPTY_WORKBENCH);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -35,7 +49,13 @@ export function OperationsDataProvider({ children }: { children: ReactNode }) {
setLoading(true);
setError(null);
try {
setModels(await listOperationsModels({ workspaceId, signal }));
const [modelsResult, workbenchResult] = await Promise.allSettled([
listOperationsModels({ workspaceId, signal }),
getOperationsWorkbench(workspaceId, signal),
]);
if (modelsResult.status === "rejected") throw modelsResult.reason;
setModels(modelsResult.value);
setWorkbench(workbenchResult.status === "fulfilled" ? workbenchResult.value : EMPTY_WORKBENCH);
} catch (cause) {
if (cause instanceof DOMException && cause.name === "AbortError") return;
setModels([]);
@@ -53,11 +73,12 @@ export function OperationsDataProvider({ children }: { children: ReactNode }) {
const value = useMemo<OperationsDataContextValue>(() => ({
models,
workbench,
loading,
error,
source: operationsApiMode,
reload: () => load(),
}), [error, load, loading, models]);
}), [error, load, loading, models, workbench]);
return <OperationsDataContext.Provider value={value}>{children}</OperationsDataContext.Provider>;
}
@@ -83,7 +104,12 @@ export function useOperationsModelDetail(modelId: string, month: string) {
setError(null);
void Promise.all([
getOperationsModel(modelId, workspaceId, controller.signal),
getMonthlyMonitoringResult(modelId, month, workspaceId, controller.signal),
getMonthlyMonitoringResult(modelId, month, workspaceId, controller.signal).catch((cause) => {
if (cause instanceof OperationsApiError && cause.code === "MONITOR_RESULT_NOT_FOUND") {
return null;
}
throw cause;
}),
]).then(([modelValue, resultValue]) => {
setModel(modelValue);
setMonitoringResult(resultValue);
@@ -106,11 +132,11 @@ export function useOperationsModelDetail(modelId: string, month: string) {
}
export function OperationsDataBoundary({ children }: { children: ReactNode }) {
const { loading, error, models, reload, source } = useOperationsData();
const { loading, error, reload } = useOperationsData();
if (loading) {
return (
<section className="h-full overflow-auto bg-bg p-6" aria-busy="true" aria-label="正在加载运维数据">
<div className="mx-auto max-w-screen-2xl space-y-6">
<div className="w-full space-y-6">
<div className="space-y-3"><Skeleton className="h-3 w-48" /><Skeleton className="h-8 w-80" /><Skeleton className="h-4 w-[36rem]" /></div>
<Skeleton className="h-40 w-full rounded-4xl" />
<div className="grid grid-cols-4 gap-4">{Array.from({ length: 4 }, (_, index) => <Skeleton className="h-32 rounded-4xl" key={index} />)}</div>
@@ -128,7 +154,7 @@ export function OperationsDataBoundary({ children }: { children: ReactNode }) {
<span className="grid size-12 place-items-center rounded-2xl bg-danger-soft text-danger"><TriangleAlert className="size-6" /></span>
<h2 className="mt-4 text-xl font-bold text-foreground"></h2>
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">{error}</p>
<p className="mt-1 text-xs text-muted-foreground">{source === "api" ? "真实接口" : "前端 Mock"}</p>
<p className="mt-1 text-xs text-muted-foreground"></p>
<Button className="mt-5" onClick={() => void reload()}><RefreshCw /></Button>
</CardContent>
</Card>
@@ -136,13 +162,5 @@ export function OperationsDataBoundary({ children }: { children: ReactNode }) {
);
}
if (!models.length) {
return (
<section className="grid h-full place-items-center bg-bg p-6">
<Card className="w-full max-w-xl"><CardContent className="flex flex-col items-center py-12 text-center"><span className="grid size-12 place-items-center rounded-2xl bg-brand-soft text-primary"><Database className="size-6" /></span><h2 className="mt-4 text-xl font-bold text-foreground"></h2><p className="mt-2 text-sm text-muted-foreground"></p><Button className="mt-5" variant="outline" onClick={() => void reload()}><RefreshCw /></Button></CardContent></Card>
</section>
);
}
return children;
}
@@ -118,6 +118,7 @@ export function FilterSelect({
onChange,
allLabel = "全部",
className,
disabled = false,
}: {
label: string;
value: string;
@@ -125,6 +126,7 @@ export function FilterSelect({
onChange: (value: string) => void;
allLabel?: string;
className?: string;
disabled?: boolean;
}) {
return (
<label className={cn("flex min-w-0 flex-col gap-1.5", className)}>
@@ -132,6 +134,7 @@ export function FilterSelect({
<span className="relative">
<select
data-slot="operations-filter-select"
disabled={disabled}
className="h-9 w-full appearance-none rounded-3xl border border-transparent bg-input/50 px-3 pr-8 text-sm text-foreground outline-none transition-colors focus:border-ring"
value={value}
onChange={(event) => onChange(event.target.value)}
@@ -223,6 +226,9 @@ export function MetricLineChart({
thresholds?: Threshold[];
comparison?: { name: string; values: number[]; tone?: "success" | "warning" };
}) {
if (!months.length || !values.length) {
return <div data-slot="metric-line-chart" className="grid min-h-48 place-items-center rounded-xl border border-dashed border-border text-sm text-muted-foreground"></div>;
}
const width = 640;
const height = 190;
const left = 48;
@@ -321,6 +327,9 @@ export function SortingComboChart({
counts: readonly number[];
badRates: readonly number[];
}) {
if (!bins.length || !counts.length || !badRates.length) {
return <div data-slot="sorting-combo-chart" className="grid min-h-64 place-items-center rounded-xl border border-dashed border-border text-sm text-muted-foreground"></div>;
}
const width = 760;
const height = 300;
const left = 56;
@@ -1,224 +1,143 @@
import { Activity, ArrowRight, Building2, FileChartColumn, Layers3, Radar } from "lucide-react";
import { useNavigate } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { GradeBadge, OperationsPageHeader } from "./OperationsUi";
import type { OperationsWorkbenchItem, OperationsWorkbenchKpi } from "~/services/operationsApi";
import { useOperationsData } from "./OperationsDataContext";
import { MODEL_CATEGORIES, categoryName, gradeOf, type ModelGrade } from "./modelData";
const FALLBACK_KPIS: OperationsWorkbenchKpi[] = [
{ key: "models", label: "在管模型", value: 0, detail: "0 家银行 · 0 个大类", target: "/operations/models", tone: "normal" },
{ key: "grades", label: "B / C 等级", value: 0, detail: "C 0 · B 0", target: "/operations/monitoring?grade=B,C", tone: "normal" },
{ key: "pending_reviews", label: "待处理结果", value: 0, detail: "到期未处理即默认暂不处理", target: "/operations/monitoring", tone: "normal" },
{ key: "intervention", label: "需主动干预", value: 0, detail: "KS < 30% 或 PSI > 50%", target: "/operations/monitoring", tone: "normal" },
{ key: "model_reports", label: "待我阅读报告", value: 0, detail: "超过 5 个工作日进入催办", target: "/operations/reports", tone: "normal" },
{ key: "stalled_workflows", label: "流程停滞", value: 0, detail: "超过节点期限未更新", target: "/operations/workflows", tone: "normal" },
];
const kpiToneClasses = {
normal: "border-border bg-card",
primary: "border-primary/25 bg-card",
warning: "border-warning/35 bg-[#fffaf5]",
danger: "border-danger/35 bg-[#fff7f6]",
} as const;
const itemToneClasses = {
normal: "bg-border",
primary: "bg-primary",
warning: "bg-warning",
danger: "bg-danger",
} as const;
function WorkbenchItem({ item, onNavigate }: { item: OperationsWorkbenchItem; onNavigate: (target: string) => void }) {
return (
<div className="flex items-start gap-3 border-b border-dashed border-border-2 px-4 py-3 last:border-b-0">
<span className={`mt-1.5 h-8 w-1 shrink-0 rounded-full ${itemToneClasses[item.tone]}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{item.title}</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">{item.detail}</div>
</div>
<Button
className="shrink-0"
variant={item.tone === "danger" ? "default" : "outline"}
size="sm"
onClick={() => onNavigate(item.target)}
>
{item.action_label}
</Button>
</div>
);
}
function EmptyList({ text }: { text: string }) {
return <div className="flex min-h-[72px] items-center justify-center px-4 py-6 text-center text-xs text-muted-foreground">{text}</div>;
}
export default function OperationsWorkbenchPage() {
const navigate = useNavigate();
const { models, source } = useOperationsData();
const liveModels = models.filter((model) => model.status !== "下线");
const bankCount = new Set(liveModels.map((model) => model.bank)).size;
const gradeCounts = liveModels.reduce<Record<ModelGrade, number>>(
(counts, model) => ({ ...counts, [gradeOf(model)]: counts[gradeOf(model)] + 1 }),
{ A: 0, B: 0, C: 0 },
);
const pending = liveModels
.filter((model) => gradeOf(model) !== "A")
.sort((left, right) => gradeOf(right).localeCompare(gradeOf(left)) || left.ks - right.ks)
.slice(0, 5);
const watchItems = [
{ title: "华东银行 · 标准A卡重构", detail: "当前阶段:方案设计 · 预计 2026-09-18 完成", path: "/operations/workflows" },
{ title: "滨海银行 · 大额A卡陪跑上线", detail: "当前阶段:模型上线 · 预计 2026-09-25 完成", path: "/operations/workflows" },
{ title: "南岭银行 · 白户A卡模型微调", detail: "当前阶段:开发评审 · 预计 2026-10-08 完成", path: "/operations/workflows" },
];
const metricCards = [
{ label: "在管模型", value: liveModels.length, detail: "覆盖 4 个模型大类", Icon: Layers3 },
{ label: "接入银行", value: bankCount, detail: "本月均已完成监控", Icon: Building2 },
{ label: "B / C 等级", value: gradeCounts.B + gradeCounts.C, detail: `${gradeCounts.C} 个需重点处理`, Icon: Radar },
{ label: "最新报告", value: 6, detail: "2026-07 监控周期", Icon: FileChartColumn },
];
const { workbench } = useOperationsData();
const kpis = workbench.kpis.length === 6 ? workbench.kpis : FALLBACK_KPIS;
const todos = workbench.todos;
const watches = workbench.watches;
const activities = workbench.activities;
const serviceOnline = workbench.kpis.length === 6;
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader
title="我的工作台"
description="独立展示模型上线后的运行状态、监控结果和待处理事项;模型开发侧功能保持不变。"
actions={(
<Button onClick={() => navigate("/operations/monitoring")}>
<Activity />
</Button>
)}
/>
<section className="h-full overflow-auto bg-bg px-6 py-5">
<div className="flex w-full flex-col gap-4 pb-8">
<section className="dashboard-hero-shell flex flex-col items-start justify-between gap-6 rounded-xl px-[34px] py-[30px] text-white shadow-lg sm:flex-row sm:items-center">
<div>
<span className="text-2xs tracking-[0.12em] opacity-80">MODEL OPERATIONS PLATFORM</span>
<h2 className="my-2 mb-[5px] text-2xl font-medium"></h2>
<p className="m-0 text-xs opacity-90"> KPI</p>
</div>
<span className="rounded-[20px] bg-white/16 px-3 py-2 text-sm">
{serviceOnline ? "服务已连接" : "服务连接中"}
</span>
</section>
<Card className="bg-[linear-gradient(125deg,var(--sidebar-background),var(--color-brand))] text-white ring-0">
<CardHeader>
<CardTitle className="text-xl font-bold text-white">2026 7 </CardTitle>
<CardDescription className="max-w-3xl text-white/80">
{liveModels.length} {gradeCounts.C} C
</CardDescription>
<CardAction>
<span className="inline-flex rounded-full bg-white/15 px-3 py-1.5 text-xs font-medium text-white">
{source === "api" ? "真实接口" : "Mock 数据"} · 2026-08-15 06:12
</span>
</CardAction>
</CardHeader>
<CardContent className="flex gap-2">
<Button className="bg-white text-brand hover:bg-white/90" onClick={() => navigate("/operations/models")}>
<ArrowRight />
</Button>
<Button className="border-white/30 bg-transparent text-white hover:bg-white/10" variant="outline" onClick={() => navigate("/operations/monitoring")}>
</Button>
<Button className="border-white/30 bg-transparent text-white hover:bg-white/10" variant="outline" onClick={() => navigate("/operations/banks")}>
</Button>
<Button className="border-white/30 bg-transparent text-white hover:bg-white/10" variant="outline" onClick={() => navigate("/operations/report-summary")}>
</Button>
</CardContent>
</Card>
{workbench.alerts.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/25 bg-danger-soft px-4 py-3 text-sm text-danger">
<span className="size-2 shrink-0 rounded-full bg-danger" />
<span className="flex-1">{workbench.alerts.join(" ")}</span>
<Button variant="outline" size="sm" onClick={() => navigate("/operations/monitoring")}></Button>
</div>
)}
<div className="grid grid-cols-4 gap-4">
{metricCards.map(({ label, value, detail, Icon }) => (
<Card key={label} size="sm">
<CardContent className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-brand-soft text-primary">
<Icon className="size-5" />
</span>
<span className="min-w-0">
<strong className="block text-xl font-bold tabular-nums text-foreground">{value}</strong>
<span className="block text-sm font-medium text-foreground">{label}</span>
<small className="block truncate text-xs text-muted-foreground">{detail}</small>
</span>
</CardContent>
</Card>
<div className="grid grid-cols-6 gap-3 max-[1180px]:grid-cols-3">
{kpis.map((kpi) => (
<button
className={`min-w-0 rounded-lg border p-4 text-left shadow-sm transition-colors hover:border-primary/45 ${kpiToneClasses[kpi.tone]}`}
key={kpi.key}
type="button"
onClick={() => navigate(kpi.target)}
title="点击查看明细"
>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="truncate">{kpi.label}</span>
</div>
<div className="mt-1 flex items-end gap-1">
<strong className="text-2xl font-semibold tabular-nums text-foreground">{kpi.value}</strong>
<span className="mb-0.5 text-lg leading-none text-muted-foreground"></span>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{kpi.detail}</div>
</button>
))}
</div>
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{(["A", "B", "C"] as ModelGrade[]).map((grade) => {
const total = liveModels.length || 1;
const count = gradeCounts[grade];
return (
<button
className="group flex w-full cursor-pointer items-center gap-3 rounded-xl border border-border bg-background p-3 text-left transition-colors hover:bg-muted/50"
key={grade}
type="button"
onClick={() => navigate(`/operations/monitoring?grade=${grade}`)}
>
<GradeBadge grade={grade} />
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between text-sm">
<b>{grade === "A" ? "运行正常" : grade === "B" ? "需要关注" : "需要处理"}</b>
<strong className="tabular-nums">{count} </strong>
</span>
<span className="mt-2 block h-2 overflow-hidden rounded-full bg-muted">
<i className="block h-full rounded-full bg-primary transition-all" style={{ width: `${count / total * 100}%` }} />
</span>
</span>
<ArrowRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
);
})}
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4 max-[1180px]:grid-cols-1">
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
<span className="text-xs text-muted-foreground"> / / </span>
<span className="ml-auto text-xs text-muted-foreground"> {todos.length} </span>
</div>
{todos.length ? todos.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无待办" />}
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardAction>
<Button variant="ghost" size="sm" onClick={() => navigate("/operations/monitoring")}></Button>
</CardAction>
</CardHeader>
<CardContent className="px-0">
<Table>
<TableHeader>
<TableRow>
<TableHead> / </TableHead>
<TableHead>ID</TableHead>
<TableHead>KS</TableHead>
<TableHead>PSI</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((model) => (
<TableRow key={model.modelId}>
<TableCell>
<strong className="block text-foreground">{model.bank}</strong>
<small className="text-muted-foreground">{categoryName(model.category)} · {model.version}</small>
</TableCell>
<TableCell className="font-mono text-xs">{model.modelId}</TableCell>
<TableCell className="tabular-nums">{model.ks.toFixed(2)}%</TableCell>
<TableCell className="tabular-nums">{model.psi.toFixed(2)}%</TableCell>
<TableCell><GradeBadge grade={gradeOf(model)} /></TableCell>
<TableCell className="text-right">
<Button variant="link" size="sm" onClick={() => navigate(`/operations/monitoring/${model.modelId}`)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="min-h-[144px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
<span className="text-xs text-muted-foreground"></span>
<span className="ml-auto text-xs text-muted-foreground"> {watches.length} </span>
</div>
{watches.length ? watches.map((item) => <WorkbenchItem item={item} key={item.id} onNavigate={navigate} />) : <EmptyList text="暂无关注事项" />}
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardAction><Button variant="ghost" size="sm" onClick={() => navigate("/operations/workflows")}></Button></CardAction>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-4">
{watchItems.map((item) => (
<div className="flex min-w-0 items-start gap-3 rounded-xl border border-border bg-background p-4" key={item.title}>
<span className="mt-1 size-2 shrink-0 rounded-full bg-primary" />
<span className="min-w-0 flex-1"><b className="block truncate text-sm text-foreground">{item.title}</b><small className="mt-1 block text-xs leading-5 text-muted-foreground">{item.detail}</small></span>
<Button className="shrink-0" variant="outline" size="xs" onClick={() => navigate(item.path)}></Button>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={() => navigate("/operations/models")}></Button>
</CardAction>
</CardHeader>
<CardContent className="grid grid-cols-4 gap-4">
{MODEL_CATEGORIES.map((category) => {
const models = liveModels.filter((model) => model.category === category.id);
return (
<button
className="group rounded-xl border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-brand-soft/50"
key={category.id}
type="button"
onClick={() => navigate(`/operations/models?category=${category.id}`)}
>
<span className="flex items-center justify-between">
<b className="text-sm text-foreground">{category.name}</b>
<ArrowRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</span>
<strong className="mt-3 block text-xl font-bold tabular-nums text-foreground">{models.length}</strong>
<small className="text-xs text-muted-foreground">
{new Set(models.map((model) => model.bank)).size} · {new Set(models.map((model) => model.version)).size}
</small>
</button>
);
})}
</CardContent>
</Card>
<div className="min-h-[156px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="border-b border-border-2 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
</div>
{activities.length ? (
<ul className="divide-y divide-dashed divide-border-2 px-4">
{activities.map((item, index) => (
<li className="flex gap-4 py-3 text-sm" key={`${item.occurred_at}-${index}`}>
<time className="w-36 shrink-0 text-xs tabular-nums text-muted-foreground">{item.occurred_at.replace("T", " ").slice(0, 16)}</time>
<span className="min-w-0 text-foreground"><b className="font-medium text-primary">{item.actor}</b> {item.text}</span>
</li>
))}
</ul>
) : <EmptyList text="暂无最近动态" />}
</div>
</div>
</section>
);
@@ -1,16 +1,14 @@
import { Fragment, useState } from "react";
import { CheckCircle2, Edit3, RotateCcw, Save, Sparkles } from "lucide-react";
import { CheckCircle2, Edit3, Save, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Textarea } from "~/components/ui/textarea";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { PROMPTS, PROMPT_REGRESSION, PROMPT_VERSIONS } from "./governanceData";
type PromptKey = keyof typeof PROMPTS;
import { PROMPTS, PROMPT_REGRESSION, PROMPT_VERSIONS, type PromptKey } from "./governanceData";
import { useCanOperationsAction } from "./operationsRole";
function PromptPreview({ text }: { text: string }) {
const parts = text.split(/(\{\{[^}]+\}\})/g);
@@ -18,30 +16,30 @@ function PromptPreview({ text }: { text: string }) {
}
export default function PromptManagementPage() {
const canEdit = useCanOperationsAction("prompts:manage");
const canRegression = useCanOperationsAction("prompts:regression");
const [promptKey, setPromptKey] = useState<PromptKey>("BC");
const [texts, setTexts] = usePersistentState<Record<PromptKey, string>>("a-card-prompt-texts", { A: PROMPTS.A.text, BC: PROMPTS.BC.text });
const [texts, setTexts] = useState<Record<PromptKey, string>>({ A: "", BC: "" });
const [editing, setEditing] = useState(false);
const [versions, setVersions] = usePersistentState("a-card-prompt-versions", PROMPT_VERSIONS);
const [versions] = useState(PROMPT_VERSIONS);
const prompt = PROMPTS[promptKey];
const passed = PROMPT_REGRESSION.filter((item) => item.result === "通过").length;
const savePrompt = () => {
const version = `P${Number(versions[0]?.version.slice(1) ?? 5) + 1}`;
setVersions((current) => [{ version, author: "模型团队 李伟", createdAt: "2026-08-31 11:05", current: true, note: "手动保存并提交回归(Mock" }, ...current.map((item) => ({ ...item, current: false }))]);
setEditing(false);
toast.success(`已保存为 ${version} 并提交回归评审`);
toast.info("Prompt保存接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="报告 Prompt 管理" description="查看和调整提示词全文,保留版本记录,并通过历史样本回归后生效。" actions={<Button onClick={savePrompt}><Save /></Button>} />
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="报告 Prompt 管理" description="查看和调整提示词全文,保留版本记录,并通过历史样本回归后生效。" actions={canRegression ? <Button onClick={savePrompt}><Save /></Button> : undefined} />
<div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary"></div>
<div className="grid grid-cols-[minmax(0,1.5fr)_minmax(20rem,0.7fr)] gap-6">
<Card>
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><Sparkles className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-72" label="报告模板" value={promptKey} allLabel="请选择" options={[{ label: PROMPTS.A.name, value: "A" }, { label: PROMPTS.BC.name, value: "BC" }]} onChange={(value) => { setPromptKey(value as PromptKey); setEditing(false); }} /><Button variant="outline" size="sm" onClick={() => setEditing((value) => !value)}><Edit3 />{editing ? "退出编辑" : "编辑"}</Button></CardAction></CardHeader>
<CardHeader className="border-b border-border"><CardTitle className="flex items-center gap-2"><Sparkles className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-72" label="报告模板" value={promptKey} allLabel="暂无模板" options={Object.entries(PROMPTS).map(([value, item]) => ({ label: item.name, value }))} onChange={(value) => { setPromptKey(value as PromptKey); setEditing(false); }} /><Button variant="outline" size="sm" disabled={!prompt || !canEdit} onClick={() => setEditing((value) => !value)}><Edit3 />{editing ? "退出编辑" : "编辑"}</Button></CardAction></CardHeader>
<CardContent className="space-y-4">
{editing ? <Textarea className="min-h-[36rem] font-mono text-xs leading-6" value={texts[promptKey]} onChange={(event) => setTexts((current) => ({ ...current, [promptKey]: event.target.value }))} /> : <PromptPreview text={texts[promptKey]} />}
{prompt ? (editing ? <Textarea className="min-h-[36rem] font-mono text-xs leading-6" value={texts[promptKey]} onChange={(event) => setTexts((current) => ({ ...current, [promptKey]: event.target.value }))} /> : <PromptPreview text={texts[promptKey]} />) : <div className="grid min-h-[36rem] place-items-center rounded-xl border border-dashed border-border text-sm text-muted-foreground"> Prompt </div>}
<p className="rounded-xl bg-muted/50 p-4 text-xs leading-5 text-muted-foreground">Prompt </p>
</CardContent>
</Card>
@@ -49,7 +47,7 @@ export default function PromptManagementPage() {
<div className="space-y-6">
<Card>
<CardHeader><CardTitle></CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-3">{versions.map((version) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={version.version}><b className="text-sm text-foreground">{version.version}</b><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{version.note}</small><small className="text-3xs text-muted-foreground">{version.author} · {version.createdAt}</small></span>{version.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : <Button variant="outline" size="icon-xs" aria-label={`回滚至 ${version.version}`} onClick={() => { setVersions((current) => current.map((item) => ({ ...item, current: item.version === version.version }))); toast.success(`Prompt 已模拟回滚至 ${version.version}`); }}><RotateCcw /></Button>}</div>)}</CardContent>
<CardContent className="space-y-3">{versions.length ? versions.map((version) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={version.version}><b className="text-sm text-foreground">{version.version}</b><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{version.note}</small><small className="text-3xs text-muted-foreground">{version.author} · {version.createdAt}</small></span>{version.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"></span>}</div>) : <div className="py-10 text-center text-sm text-muted-foreground"> Prompt </div>}</CardContent>
</Card>
<Card>
+11 -28
View File
@@ -9,7 +9,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { FilterSelect, MetricLineChart, OperationsPageHeader, SortingComboChart } from "./OperationsUi";
import { FEATURE_METRICS, MONITOR_MONTHS, SORTING_DISTRIBUTION, modelTrend } from "./modelData";
import { useOperationsData } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
import { useCanOperationsAction } from "./operationsRole";
import { latestReports, type ReportStatus, type ReportType } from "./reportData";
import { useReportStore } from "./reportStore";
@@ -29,28 +29,11 @@ function ReportTypeBadge({ type }: { type: ReportType }) {
return <span className={type === "监控报告" ? "inline-flex rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong" : "inline-flex rounded-full bg-warning-soft px-2.5 py-1 text-xs font-medium text-warning"}>{type}</span>;
}
function ScoreDistributionTable({ modelId }: { modelId: string }) {
const offset = [...modelId].reduce((sum, character) => sum + character.charCodeAt(0), 0) % 5;
const rows = [
{ bin: "(0,580]", count: 310 + offset * 7 },
{ bin: "[580,620)", count: 742 + offset * 11 },
{ bin: "[620,660)", count: 2645 + offset * 19 },
{ bin: "[660,700)", count: 4812 + offset * 23 },
{ bin: "[700,+)", count: 3657 + offset * 17 },
];
const total = rows.reduce((sum, row) => sum + row.count, 0);
let cumulative = 0;
function ScoreDistributionTable() {
return (
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader>
<TableBody>
{rows.map((row) => {
const share = row.count / total;
cumulative += share;
return <TableRow key={row.bin}><TableCell className="font-mono text-xs">{row.bin}</TableCell><TableCell className="tabular-nums">{row.count.toLocaleString("zh-CN")}</TableCell><TableCell className="tabular-nums">{(share * 100).toFixed(2)}%</TableCell><TableCell className="tabular-nums">{(cumulative * 100).toFixed(2)}%</TableCell></TableRow>;
})}
<TableRow><TableCell className="font-semibold"></TableCell><TableCell className="font-semibold tabular-nums">{total.toLocaleString("zh-CN")}</TableCell><TableCell className="font-semibold">100.00%</TableCell><TableCell></TableCell></TableRow>
</TableBody>
<TableBody><TableRow><TableCell className="h-28 text-center text-muted-foreground" colSpan={4}></TableCell></TableRow></TableBody>
</Table>
);
}
@@ -58,7 +41,6 @@ function ScoreDistributionTable({ modelId }: { modelId: string }) {
export default function ReportPage() {
const navigate = useNavigate();
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
const [searchParams] = useSearchParams();
const requestedId = searchParams.get("report");
const allReports = useReportStore((state) => state.reports);
@@ -77,8 +59,9 @@ export default function ReportPage() {
const isHistorical = Boolean(report && report.monitorMonth !== "2026-07");
const ksTrend = model ? modelTrend(model, "ks") : [];
const psiTrend = model ? modelTrend(model, "psi") : [];
const canEdit = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "report:edit");
const canSend = !isHistorical && isOperationsActionVisibleForRole(operationsRole, "report:send");
const canEdit = !isHistorical && useCanOperationsAction("report:edit");
const canExport = useCanOperationsAction("report:export");
const canSend = !isHistorical && useCanOperationsAction("report:send");
const updateFilter = <K extends keyof Filters>(key: K, value: Filters[K]) => {
setFilters((current) => ({ ...current, [key]: value }));
@@ -97,11 +80,11 @@ export default function ReportPage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title={report ? `${report.type} · ${report.bank} ${report.modelName}` : "监控诊断报告"}
description={report ? `${report.monitorMonth} 周期 · 自动生成于 ${report.generatedAt} · 报告输出日期 ${report.outputDate}` : "当前筛选条件下没有报告"}
actions={report && <><Button variant="outline" onClick={printReport}><Download /> PDF</Button>{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.success("草稿已保存,并同步至历史报告汇总(Mock)"); }}><Save />稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => { updateReport(report.reportId, { status: "已发送业务团队", unreadDays: 0, synced: true }); toast.success("已模拟发送至业务团队"); }}><Send /></Button>}</>}
actions={report && <>{canExport && <Button variant="outline" onClick={printReport}><Download /> PDF</Button>}{canEdit && <Button variant="outline" onClick={() => { updateReport(report.reportId, { status: "编辑中", synced: true }); toast.info("报告保存接口尚未接入"); }}><Save />稿</Button>}{canSend && report.status !== "已发送业务团队" && <Button onClick={() => toast.info("报告发送接口尚未接入")}><Send /></Button>}</>}
/>
<Card>
@@ -135,14 +118,14 @@ export default function ReportPage() {
<h3 className="text-xl font-bold text-foreground">{report.modelName}</h3>
<div className="grid grid-cols-4 gap-4">
{[
["验证样本", "12,186 户"], ["坏样本", "842 户"], ["KS", `${model.ks.toFixed(2)}%`], ["PSI", `${model.psi.toFixed(2)}%`],
["验证样本", ""], ["坏样本", ""], ["KS", `${model.ks.toFixed(2)}%`], ["PSI", `${model.psi.toFixed(2)}%`],
].map(([label, value]) => <div className="rounded-xl bg-muted/50 p-4" key={label}><span className="text-xs text-muted-foreground">{label}</span><strong className="mt-2 block text-xl tabular-nums text-foreground">{value}</strong></div>)}
</div>
</section>
<section className="space-y-3">
<h4 className="text-base font-semibold text-foreground"></h4>
<ScoreDistributionTable modelId={model.modelId} />
<ScoreDistributionTable />
</section>
<section className="space-y-3">
@@ -165,7 +148,7 @@ export default function ReportPage() {
</p>
</section>
<p className="rounded-xl border border-border p-4 text-xs leading-5 text-muted-foreground"> Mock</p>
<p className="rounded-xl border border-border p-4 text-xs leading-5 text-muted-foreground"></p>
</CardContent>
</Card>
</>
@@ -112,7 +112,7 @@ export default function ReportSummaryPage() {
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="历史报告汇总" description="按银行、模型、版本、模型 ID 与月份任意组合多选,查看最新和历史报告。" actions={<Button onClick={() => void exportReports(rows)}><Download /> Excel</Button>} />
<Card className="overflow-visible">
@@ -7,12 +7,11 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { AbnormalBadge, FilterSelect, GradeBadge, OperationsPageHeader } from "./OperationsUi";
import { MODEL_CATEGORIES, type ModelCategoryId, type ModelGrade, type ModelRecord } from "./modelData";
import { BASE_THRESHOLDS, MONITORING_RULES, RULE_VERSIONS, type MonitoringRule, type ThresholdConfig } from "./governanceData";
import { useOperationsData } from "./OperationsDataContext";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
import { useCanOperationsAction } from "./operationsRole";
type CategoryKey = "all" | ModelCategoryId;
@@ -42,13 +41,12 @@ function NumberField({ label, value, disabled, onChange }: { label: string; valu
export default function RuleManagementPage() {
const { models } = useOperationsData();
const operationsRole = useOperationsRole();
const canManage = isOperationsActionVisibleForRole(operationsRole, "rules:manage");
const canManage = useCanOperationsAction("rules:manage");
const initialConfigs: Record<CategoryKey, ThresholdConfig | null> = { all: { ...BASE_THRESHOLDS }, std: null, bai: null, big: null, afd: null };
const [category, setCategory] = useState<CategoryKey>("all");
const [configs, setConfigs] = usePersistentState("a-card-rule-configs", initialConfigs);
const [configs, setConfigs] = useState(initialConfigs);
const [simulation, setSimulation] = useState<ThresholdConfig>({ ...BASE_THRESHOLDS });
const [versions, setVersions] = usePersistentState("a-card-rule-versions", RULE_VERSIONS);
const [versions] = useState(RULE_VERSIONS);
const activeCut = configs[category] ?? configs.all ?? BASE_THRESHOLDS;
const editable = canManage && (category === "all" || configs[category] !== null);
const scope = models.filter((model) => model.status !== "下线" && (category === "all" || model.category === category));
@@ -86,13 +84,13 @@ export default function RuleManagementPage() {
const publishVersion = () => {
const version = `V${versions.length + 1}`;
setVersions((current) => [{ version, category: category === "all" ? "全部大类" : MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category, author: "管理员 王芳", createdAt: "2026-08-31 10:30", current: true, note: "手动发布(Mock" }, ...current.map((item) => ({ ...item, current: false }))]);
toast.success(`已模拟发布规则 ${version}`);
void version;
toast.info("规则发布接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="监控结果等级规则" description={canManage ? "维护规则矩阵、模型大类切点、版本留痕与阈值影响测算。" : "模型团队只读查看当前规则矩阵与版本记录。"} actions={canManage ? <Button onClick={publishVersion}><Save /></Button> : undefined} />
<Card>
@@ -113,7 +111,7 @@ export default function RuleManagementPage() {
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-6">
<Card>
<CardHeader><CardTitle></CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-3">{versions.map((version) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={version.version}><b className="text-sm text-foreground">{version.version}</b><span className="rounded-full bg-muted px-2.5 py-1 text-xs">{version.category}</span><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{version.note}</small><small className="text-3xs text-muted-foreground">{version.author} · {version.createdAt}</small></span>{version.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : canManage ? <Button variant="outline" size="xs" onClick={() => { setVersions((current) => current.map((item) => ({ ...item, current: item.version === version.version }))); toast.success(`已模拟回滚至 ${version.version}`); }}></Button> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"></span>}</div>)}</CardContent>
<CardContent className="space-y-3">{versions.length ? versions.map((version) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={version.version}><b className="text-sm text-foreground">{version.version}</b><span className="rounded-full bg-muted px-2.5 py-1 text-xs">{version.category}</span><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{version.note}</small><small className="text-3xs text-muted-foreground">{version.author} · {version.createdAt}</small></span>{version.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : canManage ? <Button variant="outline" size="xs" onClick={() => toast.info("规则回滚接口尚未接入")}></Button> : <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"></span>}</div>) : <div className="py-10 text-center text-sm text-muted-foreground"></div>}</CardContent>
</Card>
{canManage && <Card>
@@ -7,7 +7,6 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { BANK_REPORT_CONFIG, TEMPLATE_VERSIONS } from "./governanceData";
@@ -19,48 +18,41 @@ function nextGeneration(frequency: string, day: number): string {
}
export default function SystemConfigPage() {
const [readDay, setReadDay] = usePersistentState("a-card-config-read-day", "15 日");
const [readPeriod, setReadPeriod] = usePersistentState("a-card-config-read-period", "上一周期(月)");
const [templates, setTemplates] = usePersistentState("a-card-template-versions", TEMPLATE_VERSIONS);
const [selectedBank, setSelectedBank] = useState("江城银行");
const [bankConfigs, setBankConfigs] = usePersistentState("a-card-bank-report-config", BANK_REPORT_CONFIG);
const currentBankConfig = bankConfigs[selectedBank] ?? { frequency: "月度", day: 15 };
const notificationRows = [
["通知通道", "短信 + 平台通知"],
["报告未阅读催办", "模型团队 5 个工作日 · 业务团队 5 个工作日"],
["监控结果未处理催办", "1 个月未选择处理建议"],
["开发评审环节停滞", "1 个月未更新记录"],
["B/C 等级预警", "每月汇总,短信 + 平台通知"],
];
const [readDay, setReadDay] = useState("");
const [readPeriod, setReadPeriod] = useState("");
const [templates] = useState(TEMPLATE_VERSIONS);
const [selectedBank, setSelectedBank] = useState("");
const [bankConfigs] = useState(BANK_REPORT_CONFIG);
const currentBankConfig = selectedBank ? bankConfigs[selectedBank] : undefined;
const notificationRows: string[][] = [];
const publishTemplate = () => {
const version = `T${Number(templates[0]?.version.slice(1) ?? 4) + 1}`;
setTemplates((current) => [{ version, author: "管理员 王芳", createdAt: "2026-08-31 11:30", current: true, note: "手动发布(Mock" }, ...current.map((item) => ({ ...item, current: false }))]);
toast.success(`报告模板 ${version} 已模拟发布`);
toast.info("报告模板接口尚未接入");
};
const updateBankConfig = (updates: Partial<{ frequency: string; day: number }>) => {
setBankConfigs((current) => ({ ...current, [selectedBank]: { ...currentBankConfig, ...updates } }));
void updates;
toast.info("报告周期配置接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<OperationsPageHeader title="运维系统配置" description="维护指标读取、报告模板、通知催办和银行报告周期。" actions={<Button onClick={() => toast.success("配置已保存至前端 Mock 状态")}><Save /></Button>} />
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader title="运维系统配置" description="维护指标读取、报告模板、通知催办和银行报告周期。" actions={<Button onClick={() => toast.info("系统配置接口尚未接入")}><Save /></Button>} />
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Database className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3"><FilterSelect label="读取日(每月)" value={readDay} allLabel="请选择" options={["1 日", "15 日", "20 日"]} onChange={setReadDay} /><FilterSelect label="读取周期" value={readPeriod} allLabel="请选择" options={["上一周期(月)", "上一周期(季)"]} onChange={setReadPeriod} /></div>
<Button className="w-full" variant="outline" onClick={() => toast.success("已模拟触发共享 DB 指标同步")}><RefreshCw /></Button>
<Button className="w-full" variant="outline" onClick={() => toast.info("指标同步接口尚未接入")}><RefreshCw /></Button>
<p className="rounded-xl bg-muted/50 p-4 text-xs leading-5 text-muted-foreground">KSPSIIVCSI</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><History className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription><CardAction><Button size="sm" onClick={publishTemplate}></Button></CardAction></CardHeader>
<CardContent className="space-y-3">{templates.map((template) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={template.version}><b className="text-sm text-foreground">{template.version}</b><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{template.note}</small><small className="text-3xs text-muted-foreground">{template.author} · {template.createdAt}</small></span>{template.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : <Button variant="outline" size="xs" onClick={() => { setTemplates((current) => current.map((item) => ({ ...item, current: item.version === template.version }))); toast.success(`报告模板已模拟回滚至 ${template.version}`); }}></Button>}</div>)}</CardContent>
<CardContent className="space-y-3">{templates.length ? templates.map((template) => <div className="flex items-center gap-3 rounded-xl border border-border p-4" key={template.version}><b className="text-sm text-foreground">{template.version}</b><span className="min-w-0 flex-1"><small className="block truncate text-xs text-muted-foreground">{template.note}</small><small className="text-3xs text-muted-foreground">{template.author} · {template.createdAt}</small></span>{template.current ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : <Button variant="outline" size="xs" onClick={() => toast.info("报告模板接口尚未接入")}></Button>}</div>) : <div className="py-10 text-center text-sm text-muted-foreground"></div>}</CardContent>
</Card>
</div>
@@ -72,14 +64,14 @@ export default function SystemConfigPage() {
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Users className="size-5 text-primary" /></CardTitle><CardDescription></CardDescription></CardHeader>
<CardContent className="space-y-4"><div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary"> <code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">role_code</code></div><div className="grid grid-cols-3 gap-3">{[["业务团队", "business / business_team"], ["模型团队", "developer / model_team"], ["管理员", "admin"]].map(([label, code]) => <div className="rounded-xl border border-border p-4" key={label}><b className="block text-sm text-foreground">{label}</b><small className="mt-1 block font-mono text-xs text-muted-foreground">{code}</small></div>)}</div><p className="text-xs leading-5 text-muted-foreground"></p></CardContent>
<CardContent className="space-y-4"><div className="rounded-xl bg-brand-soft p-4 text-sm leading-6 text-primary"> <code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">role_code</code> <code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">permissions</code></div><div className="grid grid-cols-3 gap-3">{[["业务团队", "business / business_team"], ["模型团队", "developer / model_team"], ["管理员", "admin"]].map(([label, code]) => <div className="rounded-xl border border-border p-4" key={label}><b className="block text-sm text-foreground">{label}</b><small className="mt-1 block font-mono text-xs text-muted-foreground">{code}</small></div>)}</div><p className="text-xs leading-5 text-muted-foreground"></p></CardContent>
</Card>
</div>
<Card>
<CardHeader className="border-b border-border"><CardTitle></CardTitle><CardDescription> 15 </CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-44" label="选择银行" value={selectedBank} allLabel="请选择" options={Object.keys(bankConfigs)} onChange={setSelectedBank} /><Button variant="outline" size="xs" onClick={() => void exportRowsToExcel({ fileName: "报告周期配置", sheetName: "报告周期", headers: ["银行", "报告时间维度", "输出日期", "下次生成"], rows: Object.entries(bankConfigs).map(([bank, config]) => [bank, config.frequency, `每月 ${config.day}`, nextGeneration(config.frequency, config.day)]) })}><Download /> Excel</Button></CardAction></CardHeader>
<CardContent className="grid grid-cols-3 gap-3"><FilterSelect label="报告时间维度" value={currentBankConfig.frequency} allLabel="请选择" options={["月度", "季度", "半年度", "年度"]} onChange={(frequency) => updateBankConfig({ frequency })} /><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input type="number" min="1" max="28" value={currentBankConfig.day} onChange={(event) => updateBankConfig({ day: Number(event.target.value) })} /></label><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input disabled value={nextGeneration(currentBankConfig.frequency, currentBankConfig.day)} /></label></CardContent>
<CardContent className="px-0 pt-0"><Table><TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{Object.entries(bankConfigs).map(([bank, config]) => <TableRow data-state={bank === selectedBank ? "selected" : undefined} key={bank}><TableCell className="font-medium text-foreground">{bank}</TableCell><TableCell>{config.frequency}</TableCell><TableCell> {config.day} </TableCell><TableCell>{nextGeneration(config.frequency, config.day)}</TableCell></TableRow>)}</TableBody></Table></CardContent>
<CardHeader className="border-b border-border"><CardTitle></CardTitle><CardDescription> 15 </CardDescription><CardAction className="flex items-end gap-2"><FilterSelect className="w-44" label="选择银行" value={selectedBank} allLabel="暂无银行数据" options={Object.keys(bankConfigs)} onChange={setSelectedBank} /><Button variant="outline" size="xs" onClick={() => void exportRowsToExcel({ fileName: "报告周期配置", sheetName: "报告周期", headers: ["银行", "报告时间维度", "输出日期", "下次生成"], rows: Object.entries(bankConfigs).map(([bank, config]) => [bank, config.frequency, `每月 ${config.day}`, nextGeneration(config.frequency, config.day)]) })}><Download /> Excel</Button></CardAction></CardHeader>
<CardContent className="grid grid-cols-3 gap-3"><FilterSelect label="报告时间维度" value={currentBankConfig?.frequency ?? ""} allLabel="暂无配置" options={["月度", "季度", "半年度", "年度"]} disabled={!currentBankConfig} onChange={(frequency) => updateBankConfig({ frequency })} /><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input disabled={!currentBankConfig} type="number" min="1" max="28" value={currentBankConfig?.day ?? ""} onChange={(event) => updateBankConfig({ day: Number(event.target.value) })} /></label><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input disabled value={currentBankConfig ? nextGeneration(currentBankConfig.frequency, currentBankConfig.day) : "—"} /></label></CardContent>
<CardContent className="px-0 pt-0"><Table><TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{Object.entries(bankConfigs).length ? Object.entries(bankConfigs).map(([bank, config]) => <TableRow data-state={bank === selectedBank ? "selected" : undefined} key={bank}><TableCell className="font-medium text-foreground">{bank}</TableCell><TableCell>{config.frequency}</TableCell><TableCell> {config.day} </TableCell><TableCell>{nextGeneration(config.frequency, config.day)}</TableCell></TableRow>) : <TableRow><TableCell colSpan={4} className="h-28 text-center text-muted-foreground"></TableCell></TableRow>}</TableBody></Table></CardContent>
</Card>
<Card>
@@ -26,15 +26,15 @@ export default function UsageStatsPage() {
const model = USAGE_RECORDS.filter((record) => record.team === "模型团队");
const sum = (records: UsageRecord[], key: "logins" | "requests" | "reportsRead" | "reportsDownloaded") => records.reduce((total, record) => total + record[key], 0);
const metrics = [
{ label: "业务团队登录", value: sum(business, "logins"), detail: `${business.length} 人 · 人均 ${(sum(business, "logins") / business.length).toFixed(1)}`, Icon: LogIn },
{ label: "模型团队登录", value: sum(model, "logins"), detail: `${model.length} 人 · 人均 ${(sum(model, "logins") / model.length).toFixed(1)}`, Icon: LogIn },
{ label: "业务团队登录", value: sum(business, "logins"), detail: business.length ? `${business.length} 人 · 人均 ${(sum(business, "logins") / business.length).toFixed(1)}` : "暂无数据", Icon: LogIn },
{ label: "模型团队登录", value: sum(model, "logins"), detail: model.length ? `${model.length} 人 · 人均 ${(sum(model, "logins") / model.length).toFixed(1)}` : "暂无数据", Icon: LogIn },
{ label: "提交需求", value: sum(USAGE_RECORDS, "requests"), detail: "累计业务团队发起", Icon: Send },
{ label: "报告下载", value: sum(USAGE_RECORDS, "reportsDownloaded"), detail: "累计两方合计", Icon: FileDown },
];
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<OperationsPageHeader
title="平台使用统计"
description="查看业务团队与模型团队的平台使用情况,只做人员维度汇总,不展示操作内容明细。"
@@ -56,10 +56,10 @@ export default function UsageStatsPage() {
<CardContent className="px-0 pt-0">
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead></TableRow></TableHeader>
<TableBody>{rows.map((record) => {
<TableBody>{rows.length ? rows.map((record) => {
const activity = record.logins >= 40 ? ["高", "bg-success-soft text-success-strong"] : record.logins >= 15 ? ["中", "bg-brand-soft text-primary"] : ["低", "bg-warning-soft text-warning"];
return <TableRow key={`${record.team}-${record.person}`}><TableCell className="font-medium text-foreground">{record.person}</TableCell><TableCell>{record.team}</TableCell><TableCell className="tabular-nums">{record.logins}</TableCell><TableCell className="tabular-nums">{record.requests}</TableCell><TableCell className="tabular-nums">{record.reportsRead}</TableCell><TableCell className="tabular-nums">{record.reportsDownloaded}</TableCell><TableCell className="font-mono text-xs">{record.lastLoginAt}</TableCell><TableCell><span className={`rounded-full px-2.5 py-1 text-xs font-medium ${activity[1]}`}>{activity[0]}</span></TableCell></TableRow>;
})}</TableBody>
}) : <TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground">使</TableCell></TableRow>}</TableBody>
</Table>
</CardContent>
</Card>
@@ -8,11 +8,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { exportRowsToExcel } from "~/lib/exportExcel";
import { usePersistentState } from "~/hooks/use-persistent-state";
import { FilterSelect, OperationsPageHeader } from "./OperationsUi";
import { MODEL_CATEGORIES, categoryName, type ModelCategoryId } from "./modelData";
import { WORKFLOWS, WORKFLOW_STAGES, type WorkflowFile, type WorkflowInstance } from "./workflowData";
import { isOperationsActionVisibleForRole, useOperationsRole } from "./operationsRole";
import { useCanOperationsAction, useOperationsRole } from "./operationsRole";
type Filters = { bank: string; category: string; year: string; status: string; reuse: string };
const EMPTY_FILTERS: Filters = { bank: "", category: "", year: "", status: "", reuse: "" };
@@ -27,18 +26,21 @@ function statusOf(workflow: WorkflowInstance): "进行中" | "已完结" {
export default function WorkflowPage() {
const operationsRole = useOperationsRole();
const canCreate = isOperationsActionVisibleForRole(operationsRole, "workflow:create");
const canAdvance = isOperationsActionVisibleForRole(operationsRole, "workflow:advance");
const canBusinessConfirm = isOperationsActionVisibleForRole(operationsRole, "workflow:business-confirm");
const canCreate = useCanOperationsAction("workflow:create");
const canFeedback = useCanOperationsAction("workflow:feedback");
const canSubmitMaterial = useCanOperationsAction("workflow:submit-material");
const canAdvance = useCanOperationsAction("workflow:advance");
const canOnline = useCanOperationsAction("workflow:online");
const canBusinessConfirm = useCanOperationsAction("workflow:business-confirm");
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [selectedId, setSelectedId] = useState(WORKFLOWS[0]?.workflowId ?? "");
const [statisticsYear, setStatisticsYear] = useState("2026");
const [openStages, setOpenStages] = useState<Set<number>>(new Set([WORKFLOWS[0]?.currentStage ?? 1]));
const [newRequestOpen, setNewRequestOpen] = useState(false);
const [newBank, setNewBank] = useState("江城银行");
const [newBank, setNewBank] = useState("");
const [newCategory, setNewCategory] = useState("std");
const [newReuse, setNewReuse] = useState("独立开发");
const [localFiles, setLocalFiles] = usePersistentState<Record<string, WorkflowFile[]>>("a-card-workflow-local-files", {});
const [localFiles, setLocalFiles] = useState<Record<string, WorkflowFile[]>>({});
const fileInputRef = useRef<HTMLInputElement | null>(null);
const uploadTargetRef = useRef<{ workflowId: string; stage: number } | null>(null);
const filtered = useMemo(() => WORKFLOWS.filter((workflow) => (
@@ -85,12 +87,12 @@ export default function WorkflowPage() {
confirmed: target.stage === 3,
};
setLocalFiles((current) => ({ ...current, [key]: [...(current[key] ?? []), uploaded] }));
toast.success("材料已加入本地上传队列");
toast.info("材料已暂存当前页面,文件上传接口尚未接入");
};
return (
<section className="h-full overflow-auto bg-bg p-6">
<div className="mx-auto flex max-w-screen-2xl flex-col gap-6 pb-8">
<div className="flex w-full flex-col gap-6 pb-8">
<input ref={fileInputRef} className="hidden" type="file" accept=".doc,.docx,.xls,.xlsx,.pdf,.ppt,.pptx,.zip" onChange={(event) => { addWorkflowFile(event.target.files?.[0]); event.target.value = ""; }} />
<OperationsPageHeader
title="全流程进度"
@@ -146,7 +148,7 @@ export default function WorkflowPage() {
const current = workflow.currentStage === stage.stage;
const files = [...(workflow.files[stage.stage] ?? []), ...(localFiles[`${workflow.workflowId}:${stage.stage}`] ?? [])];
const open = openStages.has(stage.stage);
const canUpload = canAdvance || (canBusinessConfirm && stage.stage === 4);
const canUpload = canSubmitMaterial || (canBusinessConfirm && stage.stage === 4);
return (
<Card key={stage.stage} size="sm">
<button className="flex w-full cursor-pointer items-center gap-3 px-4 text-left" type="button" onClick={() => toggleStage(stage.stage)}>
@@ -155,7 +157,7 @@ export default function WorkflowPage() {
{current && <span className="rounded-full bg-brand-soft px-2.5 py-1 text-xs font-medium text-primary"></span>}{done && <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success-strong"></span>}{!done && !current && <span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground"></span>}
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <CardContent className="space-y-4 border-t border-border pt-4"><div className="grid grid-cols-2 gap-4"><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground"></b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.businessDuty}</p></div><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground"></b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.modelDuty}</p></div></div>{files.length ? <Table><TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="text-right"></TableHead></TableRow></TableHeader><TableBody>{files.map((file) => <TableRow key={`${file.name}-${file.uploadedAt}`}><TableCell className="font-medium text-foreground">{file.name}</TableCell><TableCell>{file.uploadedBy}</TableCell><TableCell>{file.uploadedAt}</TableCell><TableCell>{stage.stage === 3 ? <span className="rounded-full bg-muted px-2.5 py-1 text-xs"></span> : file.confirmed ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : canBusinessConfirm ? <Button variant="outline" size="xs" onClick={() => toast.success("已模拟确认材料")}></Button> : <span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning"></span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => toast.info("原始文件内容将在文件服务接入后下载")}></Button></TableCell></TableRow>)}</TableBody></Table> : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground"></div>}{current && <div className="flex flex-wrap gap-2">{canUpload && <Button variant="outline" size="sm" onClick={() => chooseWorkflowFile(workflow.workflowId, stage.stage)}><FileUp /></Button>}<Button variant="outline" size="sm" onClick={() => toast.success("已模拟发送催办通知")}><Bell /></Button>{canAdvance && stage.stage < 7 && <Button size="sm" onClick={() => toast.success("已模拟推进到下一环节")}> <ArrowRight /></Button>}</div>}</CardContent>}
{open && <CardContent className="space-y-4 border-t border-border pt-4"><div className="grid grid-cols-2 gap-4"><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground"></b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.businessDuty}</p></div><div className="rounded-xl bg-muted/50 p-4"><b className="text-sm text-foreground"></b><p className="mt-1 text-xs leading-5 text-muted-foreground">{stage.modelDuty}</p></div></div>{files.length ? <Table><TableHeader><TableRow><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="text-right"></TableHead></TableRow></TableHeader><TableBody>{files.map((file) => <TableRow key={`${file.name}-${file.uploadedAt}`}><TableCell className="font-medium text-foreground">{file.name}</TableCell><TableCell>{file.uploadedBy}</TableCell><TableCell>{file.uploadedAt}</TableCell><TableCell>{stage.stage === 3 ? <span className="rounded-full bg-muted px-2.5 py-1 text-xs"></span> : file.confirmed ? <span className="rounded-full bg-success-soft px-2.5 py-1 text-xs text-success-strong"></span> : canBusinessConfirm ? <Button variant="outline" size="xs" onClick={() => toast.info("材料确认接口尚未接入")}></Button> : <span className="rounded-full bg-warning-soft px-2.5 py-1 text-xs text-warning"></span>}</TableCell><TableCell className="text-right"><Button variant="link" size="sm" onClick={() => toast.info("原始文件内容将在文件服务接入后下载")}></Button></TableCell></TableRow>)}</TableBody></Table> : <div className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground"></div>}{current && <div className="flex flex-wrap gap-2">{canUpload && <Button variant="outline" size="sm" onClick={() => chooseWorkflowFile(workflow.workflowId, stage.stage)}><FileUp /></Button>}{canFeedback && stage.stage === 2 && <Button variant="outline" size="sm" onClick={() => toast.info("流程反馈接口尚未接入")}></Button>}{canBusinessConfirm && stage.stage === 5 && <Button variant="outline" size="sm" onClick={() => toast.info("流程确认接口尚未接入")}></Button>}<Button variant="outline" size="sm" onClick={() => toast.info("催办通知接口尚未接入")}><Bell /></Button>{canAdvance && stage.stage < 7 && <Button size="sm" onClick={() => toast.info("流程推进接口尚未接入")}> <ArrowRight /></Button>}{canOnline && stage.stage === 7 && <Button size="sm" onClick={() => toast.info("模型上线接口尚未接入")}>线 <ArrowRight /></Button>}</div>}</CardContent>}
</Card>
);
})}
@@ -168,7 +170,7 @@ export default function WorkflowPage() {
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle><DialogDescription></DialogDescription></DialogHeader>
<div className="space-y-4"><label className="flex flex-col gap-1.5"><span className="text-xs font-medium text-ink-caption"></span><Input value={newBank} onChange={(event) => setNewBank(event.target.value)} placeholder="多个银行使用顿号分隔" /></label><FilterSelect label="模型大类" value={newCategory} allLabel="请选择" options={MODEL_CATEGORIES.map((item) => ({ label: item.name, value: item.id }))} onChange={setNewCategory} /><FilterSelect label="模型来源" value={newReuse} allLabel="请选择" options={["独立开发", "复用通用模型"]} onChange={setNewReuse} /></div>
<DialogFooter><Button variant="outline" onClick={() => setNewRequestOpen(false)}></Button><Button onClick={() => { if (!newBank.trim()) { toast.error("请输入银行"); return; } setNewRequestOpen(false); toast.success(`已模拟发起需求:${newBank} · ${categoryName(newCategory as ModelCategoryId)} · ${newReuse}`); }}></Button></DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setNewRequestOpen(false)}></Button><Button onClick={() => { if (!newBank.trim()) { toast.error("请输入银行"); return; } setNewRequestOpen(false); toast.info("需求提交接口尚未接入"); }}></Button></DialogFooter>
</DialogContent>
</Dialog>
</section>
@@ -37,65 +37,17 @@ export const CATEGORY_THRESHOLDS: Record<ModelCategoryId, ThresholdConfig | null
afd: null,
};
export const MONITORING_RULES: MonitoringRule[] = [
{ id: 1, ranking: "不符", ksBand: "<40%", psiBand: ">10%", dropBand: "无条件", abnormal: "三级", grade: "C", reason: "排序性不符 + KS 偏低 + PSI 偏移", action: "诊断报告,评估模型微调或重构必要性" },
{ id: 2, ranking: "相符", ksBand: "<40%", psiBand: ">25%", dropBand: "无条件", abnormal: "三级", grade: "C", reason: "KS 偏低 + PSI 明显偏移", action: "诊断报告,评估模型微调或重构必要性" },
{ id: 3, ranking: "不符", ksBand: ">=40%", psiBand: ">25%", dropBand: "无条件", abnormal: "二级", grade: "B", reason: "排序性不符、PSI 高", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 4, ranking: "不符", ksBand: ">=40%", psiBand: "10%-25%", dropBand: ">20%", abnormal: "二级", grade: "B", reason: "排序性不符、PSI 中、KS 环比降幅高", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 5, ranking: "不符", ksBand: "<40%", psiBand: "<=10%", dropBand: "无条件", abnormal: "二级", grade: "B", reason: "排序性不符、KS 低", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 6, ranking: "相符", ksBand: "<40%", psiBand: "<=25%", dropBand: "无条件", abnormal: "二级", grade: "B", reason: "KS 低", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 7, ranking: "相符", ksBand: ">=40%", psiBand: ">25%", dropBand: ">20%", abnormal: "二级", grade: "B", reason: "PSI 高、KS 环比降幅高", action: "诊断报告;累计升级后评估微调或重构" },
{ id: 8, ranking: "不符", ksBand: ">=40%", psiBand: "10%-25%", dropBand: "<=20%", abnormal: "一级", grade: "B", reason: "排序性不符 + PSI 轻度偏移", action: "诊断报告" },
{ id: 9, ranking: "不符", ksBand: ">=40%", psiBand: "<=10%", dropBand: "无条件", abnormal: "一级", grade: "B", reason: "排序性不符", action: "诊断报告" },
{ id: 10, ranking: "相符", ksBand: ">=40%", psiBand: ">25%", dropBand: "<=20%", abnormal: "一级", grade: "B", reason: "PSI 明显偏移", action: "诊断报告" },
{ id: 11, ranking: "相符", ksBand: ">=40%", psiBand: "10%-25%", dropBand: "无条件", abnormal: "一级", grade: "B", reason: "PSI 轻度偏移", action: "诊断报告" },
{ id: 12, ranking: "相符", ksBand: ">=40%", psiBand: "<=10%", dropBand: ">20%", abnormal: "一级", grade: "B", reason: "KS 环比下滑明显", action: "诊断报告" },
{ id: 13, ranking: "相符", ksBand: ">=40%", psiBand: "<=10%", dropBand: "<=20%", abnormal: "正常", grade: "A", reason: "各项指标均在阈值内", action: "监控报告" },
];
export const MONITORING_RULES: MonitoringRule[] = [];
export const RULE_VERSIONS = [
{ version: "V3", category: "全部大类", author: "管理员 王芳", createdAt: "2026-07-02 10:24", current: true, note: "新增 KS 环比降幅维度" },
{ version: "V2", category: "全部大类", author: "管理员 王芳", createdAt: "2026-03-15 14:08", current: false, note: "PSI 分档由两档改三档" },
{ version: "V1", category: "全部大类", author: "管理员 王芳", createdAt: "2025-11-20 09:41", current: false, note: "初版" },
];
export const RULE_VERSIONS: Array<{ version: string; category: string; author: string; createdAt: string; current: boolean; note: string }> = [];
export const PROMPTS = {
A: {
name: "监控报告模板(A 等级)",
text: `你是一名信贷风险模型分析师。请依据平台程序取数的指标,撰写月度模型监控报告。\n\n【硬性要求】\n1. 所有数值只使用给定值,禁止自行计算。\n2. 正文不得出现 A/B/C 等级、判级或评级字样。\n3. 仅撰写监控结论、排序性、KS、PSI 四节。\n4. 语气客观,不做超出数据的推断。\n\n【平台注入变量】\n银行:{{bank}}\n模型名称:{{model_name}}\n模型版本:{{model_ver}}\n模型ID{{model_id}}\n报告周期:{{period}}\n排序性:{{rank_result}}\nKS{{ks}}%\nKS 环比降幅:{{ks_drop}}%\nPSI(滚动基准期):{{psi_roll}}%\n近 6 期趋势:{{trend_6m}}`,
},
BC: {
name: "诊断报告模板(B / C 等级)",
text: `你是一名信贷风险模型分析师。请依据平台程序取数的指标,撰写月度模型诊断报告。\n\n【硬性要求】\n1. 所有数值只使用给定值,禁止自行计算。\n2. 正文不得出现 A/B/C 等级、判级或评级字样。\n3. 结构包含监控结论、排序性、KS、PSI、IV、CSI、归因与建议。\n4. 归因只能基于命中规则与特征级指标,不得臆测业务原因。\n\n【平台注入变量】\n银行:{{bank}}\n模型名称:{{model_name}}\n模型版本:{{model_ver}}\n模型ID{{model_id}}\n报告周期:{{period}}\n排序性:{{rank_result}}\nKS{{ks}}%\nKS 环比降幅:{{ks_drop}}%\nPSI(滚动):{{psi_roll}}%\n命中规则:{{hit_rule}}\n异常等级:{{ab_level}}\n近 6 个月二级次数:{{lv2_6m}}\n特征级 IV{{iv_by_feature}}\n特征级 CSI{{csi_by_feature}}\n各特征分布变化:{{dist_shift}}`,
},
} as const;
export type PromptKey = "A" | "BC";
export const PROMPTS: Partial<Record<PromptKey, { name: string; text: string }>> = {};
export const PROMPT_VERSIONS = [
{ version: "P5", author: "模型团队 李伟", createdAt: "2026-08-02 15:20", current: true, note: "加入禁止自行计算硬约束" },
{ version: "P4", author: "模型团队 李伟", createdAt: "2026-06-11 10:05", current: false, note: "正文禁止出现等级字样" },
{ version: "P3", author: "模型团队 王芳", createdAt: "2026-04-08 16:48", current: false, note: "归因段落收敛,禁止臆测业务原因" },
];
export const PROMPT_VERSIONS: Array<{ version: string; author: string; createdAt: string; current: boolean; note: string }> = [];
export const PROMPT_REGRESSION = [
{ sample: "2026-06 江城银行 标准A卡", result: "通过", detail: "数值一致,无等级字样" },
{ sample: "2026-06 通汇银行 标准A卡", result: "通过", detail: "数值一致,归因引用命中规则" },
{ sample: "2026-05 华东银行 反欺诈评分", result: "通过", detail: "—" },
{ sample: "2026-05 南岭银行 白户A卡", result: "待复核", detail: "CSI 表述偏笼统" },
{ sample: "2026-04 云岭银行 标准A卡", result: "通过", detail: "—" },
];
export const PROMPT_REGRESSION: Array<{ sample: string; result: string; detail: string }> = [];
export const TEMPLATE_VERSIONS = [
{ version: "T4", author: "管理员 王芳", createdAt: "2026-08-01 16:30", current: true, note: "诊断报告增加 IV / CSI 段落" },
{ version: "T3", author: "管理员 王芳", createdAt: "2026-05-18 11:12", current: false, note: "隐藏监控结果等级字样" },
{ version: "T2", author: "管理员 王芳", createdAt: "2026-02-09 09:55", current: false, note: "调整排序性图表位置" },
];
export const TEMPLATE_VERSIONS: Array<{ version: string; author: string; createdAt: string; current: boolean; note: string }> = [];
export const BANK_REPORT_CONFIG: Record<string, { frequency: string; day: number }> = {
: { frequency: "月度", day: 15 },
: { frequency: "月度", day: 15 },
: { frequency: "月度", day: 18 },
: { frequency: "季度", day: 15 },
: { frequency: "月度", day: 20 },
: { frequency: "半年度", day: 15 },
: { frequency: "年度", day: 15 },
};
export const BANK_REPORT_CONFIG: Record<string, { frequency: string; day: number }> = {};
+40 -212
View File
@@ -43,134 +43,25 @@ export const MONITOR_MONTHS = [
"2026-07",
] as const;
export const MODELS: ModelRecord[] = [
{
bank: "江城银行", category: "std", name: "标准A卡", modelId: "JC-STD-001", version: "v2.3",
status: "正常", iteratedAt: "2026-06-18", ranking: "不符", ks: 36.84, psi: 27.4,
ksDrop: 24.1, secondaryHits: 3, wuji: true, processedAt: "2026-06-18",
previousAdvice: "建议启动模型微调或重构评估", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "滨海银行", category: "std", name: "标准A卡", modelId: "BH-STD-001", version: "v2.3",
status: "陪跑", iteratedAt: "2026-07-28", ranking: "相符", ks: 44.1, psi: 6.2,
ksDrop: 3.4, secondaryHits: 0, wuji: false, processedAt: "2026-06-03",
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "华东银行", category: "std", name: "标准A卡", modelId: "HD-STD-001", version: "v2.2",
status: "正常", iteratedAt: "2026-03-20", ranking: "相符", ks: 43.5, psi: 8.8,
ksDrop: 6.1, secondaryHits: 0, wuji: false, processedAt: "2026-05-20",
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "南岭银行", category: "std", name: "标准A卡", modelId: "NL-STD-001", version: "v1.6",
status: "正常", iteratedAt: "2025-08-22", ranking: "相符", ks: 41.12, psi: 13.6,
ksDrop: 22.5, secondaryHits: 1, wuji: false, processedAt: "2026-06-12",
previousAdvice: "建议重点关注 PSI 与 KS 环比变化", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "云岭银行", category: "std", name: "标准A卡", modelId: "YL-STD-001", version: "v2.1",
status: "正常", iteratedAt: "2026-01-09", ranking: "相符", ks: 45.8, psi: 5.1,
ksDrop: 2.2, secondaryHits: 0, wuji: false, processedAt: "2026-04-28",
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "通汇银行", category: "std", name: "标准A卡", modelId: "TH-STD-001", version: "v2.0",
status: "正常", iteratedAt: "2025-11-30", ranking: "不符", ks: 38.73, psi: 9.4,
ksDrop: 11.8, secondaryHits: 4, wuji: false, processedAt: "2026-05-16",
previousAdvice: "建议开展模型微调可行性评估", commonModel: "标准A卡通用版 v2.3",
},
{
bank: "北岸银行", category: "std", name: "标准A卡", modelId: "BA-STD-001", version: "v1.9",
status: "下线", iteratedAt: "2024-12-11", ranking: "—", ks: 0, psi: 0,
ksDrop: 0, secondaryHits: 0, wuji: false, processedAt: "2026-03-02",
previousAdvice: "模型已下线,不再参与月度监控", commonModel: null,
},
{
bank: "江城银行", category: "big", name: "大额A卡", modelId: "JC-BIG-001", version: "v1.4",
status: "正常", iteratedAt: "2026-07-03", ranking: "相符", ks: 46.3, psi: 4.8,
ksDrop: 1.5, secondaryHits: 0, wuji: false, processedAt: "2026-06-24",
previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "华东银行", category: "big", name: "大额A卡", modelId: "HD-BIG-001", version: "v1.3",
status: "正常", iteratedAt: "2026-04-16", ranking: "相符", ks: 44.9, psi: 7.3,
ksDrop: 4.9, secondaryHits: 0, wuji: false, processedAt: "2026-05-30",
previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "云岭银行", category: "big", name: "大额A卡", modelId: "YL-BIG-001", version: "v1.2",
status: "陪跑结束", iteratedAt: "2026-08-05", ranking: "相符", ks: 42.7, psi: 9.6,
ksDrop: 8.2, secondaryHits: 0, wuji: false, processedAt: null,
previousAdvice: "暂无历史处理建议", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "滨海银行", category: "big", name: "大额A卡", modelId: "BH-BIG-001", version: "v1.1",
status: "正常", iteratedAt: "2025-09-25", ranking: "相符", ks: 39.4, psi: 11.2,
ksDrop: 9.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-19",
previousAdvice: "建议持续跟踪,下期复核", commonModel: "大额A卡通用版 v1.4",
},
{
bank: "南岭银行", category: "bai", name: "白户A卡", modelId: "NL-BAI-001", version: "v1.5",
status: "正常", iteratedAt: "2026-05-22", ranking: "不符", ks: 34.2, psi: 18.7,
ksDrop: 15.3, secondaryHits: 2, wuji: true, processedAt: "2026-06-09",
previousAdvice: "建议启动模型微调或重构评估", commonModel: null,
},
{
bank: "通汇银行", category: "bai", name: "白户A卡", modelId: "TH-BAI-001", version: "v1.4",
status: "正常", iteratedAt: "2026-02-11", ranking: "相符", ks: 37.6, psi: 12.1,
ksDrop: 7.4, secondaryHits: 1, wuji: false, processedAt: "2026-05-11",
previousAdvice: "建议重点关注并持续跟踪", commonModel: "白户A卡通用版 v1.4",
},
{
bank: "江城银行", category: "bai", name: "白户A卡", modelId: "JC-BAI-001", version: "v1.3",
status: "正常", iteratedAt: "2025-12-05", ranking: "相符", ks: 40.8, psi: 9.9,
ksDrop: 5.6, secondaryHits: 0, wuji: false, processedAt: "2026-03-26",
previousAdvice: "维持现状,继续监控", commonModel: "白户A卡通用版 v1.4",
},
{
bank: "华东银行", category: "afd", name: "反欺诈评分", modelId: "HD-AFD-001", version: "v2.0",
status: "正常", iteratedAt: "2026-04-10", ranking: "不符", ks: 28.6, psi: 31.5,
ksDrop: 18.9, secondaryHits: 3, wuji: false, processedAt: "2026-06-21",
previousAdvice: "建议启动模型微调或重构评估", commonModel: null,
},
{
bank: "滨海银行", category: "afd", name: "反欺诈评分", modelId: "BH-AFD-001", version: "v1.8",
status: "正常", iteratedAt: "2025-10-30", ranking: "相符", ks: 36.4, psi: 8.1,
ksDrop: 4.2, secondaryHits: 3, wuji: true, processedAt: "2026-05-25",
previousAdvice: "建议重点关注并持续跟踪", commonModel: "反欺诈评分通用版 v1.8",
},
{
bank: "云岭银行", category: "afd", name: "反欺诈评分", modelId: "YL-AFD-001", version: "v1.7",
status: "正常", iteratedAt: "2025-08-19", ranking: "相符", ks: 38.2, psi: 14.6,
ksDrop: 12.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-15",
previousAdvice: "建议持续跟踪,下期复核", commonModel: null,
},
];
export const FEATURE_METRICS = [
{ key: "age", name: "申请人年龄", iv: 0.284, ivDrop: 6.8, csi: 0.041, csiRise: 0.8, ksContribution: -0.7, psiContribution: 1.2 },
{ key: "income", name: "月均收入", iv: 0.251, ivDrop: 12.4, csi: 0.058, csiRise: 1.7, ksContribution: -1.2, psiContribution: 2.4 },
{ key: "debt_ratio", name: "负债收入比", iv: 0.219, ivDrop: 18.6, csi: 0.076, csiRise: 2.9, ksContribution: -2.1, psiContribution: 4.1 },
{ key: "credit_age", name: "信贷账龄", iv: 0.193, ivDrop: 21.3, csi: 0.083, csiRise: 3.5, ksContribution: -2.8, psiContribution: 5.2 },
{ key: "query_3m", name: "近三月查询次数", iv: 0.171, ivDrop: 24.7, csi: 0.091, csiRise: 4.2, ksContribution: -3.4, psiContribution: 6.8 },
] as const;
// 业务数据全部来自后端。以下空集合仅保留页面类型和计算函数的接口,
// 在真实接口补齐对应字段前不再提供本地样例数据。
export const MODELS: ModelRecord[] = [];
export const FEATURE_METRICS: Array<{
key: string;
name: string;
iv: number;
ivDrop: number;
csi: number;
csiRise: number;
ksContribution: number;
psiContribution: number;
}> = [];
export const SORTING_DISTRIBUTION = {
bins: ["(0,580]", "[580,600)", "[600,620)", "[620,640)", "[640,660)", "[660,680)", "[680,+)"],
counts: [307, 418, 730, 1100, 1545, 1895, 6104],
badRates: [10.42, 5.98, 5.48, 3.64, 2.85, 1.64, 0.31],
} as const;
export const BANK_AVERAGE_CYCLE: Record<string, number> = {
江城银行: 8.6,
滨海银行: 11.2,
华东银行: 9.8,
南岭银行: 12.4,
云岭银行: 10.1,
通汇银行: 13.6,
北岸银行: 16.0,
bins: [] as string[],
counts: [] as number[],
badRates: [] as number[],
};
export const BANK_AVERAGE_CYCLE: Record<string, number> = {};
export type ModelLifecycle = {
onlineAt: string | null;
@@ -183,25 +74,7 @@ export type ModelLifecycle = {
maxLift: number;
};
export const MODEL_LIFECYCLE: Record<string, ModelLifecycle> = {
"JC-STD-001": { onlineAt: "2025-03-12", escortStartAt: "2025-01-20", escortEndAt: "2025-03-10", offlineAt: null, developer: "李伟", developmentKs: 46.0, developmentPsi: 3.92, maxLift: 3.24 },
"BH-STD-001": { onlineAt: null, escortStartAt: "2026-07-28", escortEndAt: null, offlineAt: null, developer: "王芳", developmentKs: 47.5, developmentPsi: 3.48, maxLift: 3.37 },
"HD-STD-001": { onlineAt: "2024-11-05", escortStartAt: "2024-09-18", escortEndAt: "2024-11-01", offlineAt: null, developer: "李伟", developmentKs: 46.9, developmentPsi: 3.71, maxLift: 3.18 },
"NL-STD-001": { onlineAt: "2025-08-22", escortStartAt: "2025-06-30", escortEndAt: "2025-08-18", offlineAt: null, developer: "王芳", developmentKs: 45.4, developmentPsi: 4.06, maxLift: 3.09 },
"YL-STD-001": { onlineAt: "2025-05-14", escortStartAt: "2025-03-22", escortEndAt: "2025-05-10", offlineAt: null, developer: "李伟", developmentKs: 48.2, developmentPsi: 3.26, maxLift: 3.42 },
"TH-STD-001": { onlineAt: "2024-06-18", escortStartAt: "2024-04-25", escortEndAt: "2024-06-14", offlineAt: null, developer: "王芳", developmentKs: 44.8, developmentPsi: 4.31, maxLift: 2.96 },
"BA-STD-001": { onlineAt: "2023-09-01", escortStartAt: null, escortEndAt: null, offlineAt: "2026-02-28", developer: "李伟", developmentKs: 42.7, developmentPsi: 4.62, maxLift: 2.81 },
"JC-BIG-001": { onlineAt: "2025-10-08", escortStartAt: "2025-08-15", escortEndAt: "2025-10-05", offlineAt: null, developer: "王芳", developmentKs: 49.1, developmentPsi: 3.05, maxLift: 3.55 },
"HD-BIG-001": { onlineAt: "2025-02-19", escortStartAt: "2024-12-20", escortEndAt: "2025-02-15", offlineAt: null, developer: "李伟", developmentKs: 47.8, developmentPsi: 3.37, maxLift: 3.31 },
"YL-BIG-001": { onlineAt: "2026-08-05", escortStartAt: "2026-06-10", escortEndAt: "2026-08-01", offlineAt: null, developer: "王芳", developmentKs: 46.6, developmentPsi: 3.68, maxLift: 3.12 },
"BH-BIG-001": { onlineAt: "2024-12-03", escortStartAt: "2024-10-11", escortEndAt: "2024-11-29", offlineAt: null, developer: "李伟", developmentKs: 43.9, developmentPsi: 4.25, maxLift: 2.91 },
"NL-BAI-001": { onlineAt: "2025-07-16", escortStartAt: "2025-05-20", escortEndAt: "2025-07-12", offlineAt: null, developer: "王芳", developmentKs: 42.6, developmentPsi: 4.74, maxLift: 2.73 },
"TH-BAI-001": { onlineAt: "2025-04-09", escortStartAt: "2025-02-14", escortEndAt: "2025-04-05", offlineAt: null, developer: "李伟", developmentKs: 43.7, developmentPsi: 4.18, maxLift: 2.88 },
"JC-BAI-001": { onlineAt: "2024-08-27", escortStartAt: "2024-07-01", escortEndAt: "2024-08-23", offlineAt: null, developer: "王芳", developmentKs: 45.1, developmentPsi: 3.89, maxLift: 3.03 },
"HD-AFD-001": { onlineAt: "2025-01-22", escortStartAt: "2024-11-28", escortEndAt: "2025-01-18", offlineAt: null, developer: "李伟", developmentKs: 40.8, developmentPsi: 5.16, maxLift: 2.54 },
"BH-AFD-001": { onlineAt: "2024-10-14", escortStartAt: "2024-08-20", escortEndAt: "2024-10-10", offlineAt: null, developer: "王芳", developmentKs: 42.3, developmentPsi: 4.69, maxLift: 2.68 },
"YL-AFD-001": { onlineAt: "2024-05-08", escortStartAt: "2024-03-15", escortEndAt: "2024-05-04", offlineAt: null, developer: "李伟", developmentKs: 43.5, developmentPsi: 4.42, maxLift: 2.79 },
};
export const MODEL_LIFECYCLE: Record<string, ModelLifecycle> = {};
export function categoryName(category: ModelCategoryId): string {
return MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category;
@@ -252,55 +125,16 @@ export function abnormalReasonOf(model: ModelRecord | MonitoringRow): string {
return reasons.join("、");
}
function hash(value: string): number {
let result = 0;
for (const character of value) result = (result * 31 + character.charCodeAt(0)) >>> 0;
return result;
}
export function modelTrend(
model: ModelRecord,
metric: "ks" | "psi",
months: readonly string[] = MONITOR_MONTHS,
_model: ModelRecord,
_metric: "ks" | "psi",
_months: readonly string[] = MONITOR_MONTHS,
): number[] {
const end = model[metric];
const amplitude = metric === "ks" ? 2.6 : 4.4;
const seed = hash(`${model.modelId}-${metric}`);
const waveAt = (index: number) => (((seed >> (index % 12)) % 9) - 4) * (amplitude / 12);
const lastWave = waveAt(Math.max(0, months.length - 1));
return months.map((_, index) => {
const distance = months.length - 1 - index;
const drift = metric === "ks" ? distance * 0.32 : -distance * 0.74;
const wave = waveAt(index) - lastWave;
return Math.max(0, Number((end + drift + wave).toFixed(2)));
});
return [];
}
export function monitoringRows(source: ModelRecord[] = MODELS): MonitoringRow[] {
const rows: MonitoringRow[] = [];
for (const model of source) {
if (model.status === "下线") {
rows.push({ ...model, monitorMonth: "2026-02", ranking: "相符", ks: 39.8, psi: 9.1, ksDrop: 6.2, secondaryHits: 1 });
continue;
}
const ksTrend = modelTrend(model, "ks");
const psiTrend = modelTrend(model, "psi");
MONITOR_MONTHS.forEach((monitorMonth, index) => {
rows.push({
...model,
monitorMonth,
ks: ksTrend[index] ?? model.ks,
psi: psiTrend[index] ?? model.psi,
ksDrop: Math.max(0, Number((model.ksDrop - (MONITOR_MONTHS.length - 1 - index) * 0.9).toFixed(2))),
secondaryHits: Math.max(0, model.secondaryHits - (MONITOR_MONTHS.length - 1 - index)),
});
});
}
return rows.sort((left, right) => (
right.monitorMonth.localeCompare(left.monitorMonth)
|| left.bank.localeCompare(right.bank, "zh-CN")
|| left.modelId.localeCompare(right.modelId)
));
export function monitoringRows(_source: ModelRecord[] = MODELS): MonitoringRow[] {
return [];
}
export function monthsBetween(from: string, to: string): string[] {
@@ -323,35 +157,29 @@ export function average(values: number[]): number {
}
export function categoryTrend(
category: ModelCategoryId,
metric: "ks" | "psi",
months: string[],
source: ModelRecord[] = MODELS,
_category: ModelCategoryId,
_metric: "ks" | "psi",
_months: string[],
_source: ModelRecord[] = MODELS,
): number[] {
const models = source.filter((model) => model.category === category && model.status !== "下线");
const series = models.map((model) => modelTrend(model, metric, months));
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
return [];
}
export function bankTrend(
bank: string,
metric: "ks" | "psi",
months: string[],
source: ModelRecord[] = MODELS,
_bank: string,
_metric: "ks" | "psi",
_months: string[],
_source: ModelRecord[] = MODELS,
): number[] {
const models = source.filter((model) => model.bank === bank && model.status !== "下线");
const series = models.map((model) => modelTrend(model, metric, months));
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
return [];
}
export function modelsTrend(
models: ModelRecord[],
metric: "ks" | "psi",
months: string[],
_models: ModelRecord[],
_metric: "ks" | "psi",
_months: string[],
): number[] {
const activeModels = models.filter((model) => model.status !== "下线");
const series = activeModels.map((model) => modelTrend(model, metric, months));
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
return [];
}
export function latestIterationDate(models: ModelRecord[]): string {
@@ -368,9 +196,9 @@ export function modelIterationCycleMonths(model: ModelRecord): number | null {
return Math.max(0, (endYear * 12 + endMonth) - (startYear * 12 + startMonth));
}
export function averageIterationCycle(models: ModelRecord[]): number {
export function averageIterationCycle(models: ModelRecord[]): number | null {
const values = models.map(modelIterationCycleMonths).filter((value): value is number => value !== null);
return Number(average(values).toFixed(1));
return values.length ? Number(average(values).toFixed(1)) : null;
}
export function averageIterationMonths(models: ModelRecord[]): number {
@@ -8,6 +8,7 @@ export type OperationsPageKey =
| "banks"
| "deployed-models"
| "monitoring"
| "monitoring-detail"
| "reports"
| "report-summary"
| "workflows"
@@ -18,14 +19,22 @@ export type OperationsPageKey =
export type OperationsAction =
| "workflow:create"
| "workflow:feedback"
| "workflow:submit-material"
| "workflow:advance"
| "workflow:online"
| "workflow:business-confirm"
| "monitor:initial-review"
| "monitor:final-review"
| "report:edit"
| "report:export"
| "report:send"
| "rules:publish"
| "rules:rollback"
| "rules:simulate"
| "rules:manage"
| "prompts:manage"
| "prompts:regression"
| "settings:manage";
const PAGE_ROLE_VISIBILITY: Record<OperationsPageKey, OperationsRole[]> = {
@@ -35,6 +44,7 @@ const PAGE_ROLE_VISIBILITY: Record<OperationsPageKey, OperationsRole[]> = {
banks: ["business", "model", "admin"],
"deployed-models": ["model", "admin"],
monitoring: ["business", "model", "admin"],
"monitoring-detail": ["business", "model", "admin"],
reports: ["business", "model", "admin"],
"report-summary": ["business", "model", "admin"],
workflows: ["business", "model", "admin"],
@@ -46,17 +56,63 @@ const PAGE_ROLE_VISIBILITY: Record<OperationsPageKey, OperationsRole[]> = {
const ACTION_ROLE_VISIBILITY: Record<OperationsAction, OperationsRole[]> = {
"workflow:create": ["business", "admin"],
"workflow:feedback": ["business", "admin"],
"workflow:submit-material": ["model", "admin"],
"workflow:advance": ["model", "admin"],
"workflow:online": ["model", "admin"],
"workflow:business-confirm": ["business", "admin"],
"monitor:initial-review": ["model", "admin"],
"monitor:final-review": ["business", "admin"],
"report:edit": ["model", "admin"],
"report:export": ["business", "model", "admin"],
"report:send": ["model", "admin"],
"rules:publish": ["admin"],
"rules:rollback": ["admin"],
"rules:simulate": ["admin"],
"rules:manage": ["admin"],
"prompts:manage": ["model", "admin"],
"prompts:regression": ["model", "admin"],
"settings:manage": ["admin"],
};
export const OPERATIONS_PAGE_PERMISSIONS: Record<OperationsPageKey, string> = {
workbench: "operations:workbench:view",
usage: "operations:usage:view",
models: "operations:model-overview:view",
banks: "operations:bank-overview:view",
"deployed-models": "operations:deployed-models:view",
monitoring: "operations:monitoring-overview:view",
"monitoring-detail": "operations:monitoring-detail:view",
reports: "operations:report:view",
"report-summary": "operations:report-summary:view",
workflows: "operations:workflow:view",
knowledge: "operations:knowledge:view",
rules: "operations:rules:view",
prompts: "operations:prompt:view",
settings: "operations:settings:view",
};
export const OPERATIONS_ACTION_PERMISSIONS: Record<OperationsAction, string> = {
"workflow:create": "operations:workflow:create",
"workflow:feedback": "operations:workflow:feedback",
"workflow:submit-material": "operations:workflow:submit-material",
"workflow:advance": "operations:workflow:advance",
"workflow:online": "operations:workflow:online",
"workflow:business-confirm": "operations:workflow:confirm",
"monitor:initial-review": "operations:monitoring-detail:model-review",
"monitor:final-review": "operations:monitoring-detail:business-review",
"report:edit": "operations:report:edit",
"report:export": "operations:report:export",
"report:send": "operations:report:send",
"rules:publish": "operations:rules:publish",
"rules:rollback": "operations:rules:rollback",
"rules:simulate": "operations:rules:simulate",
"rules:manage": "operations:rules:publish",
"prompts:manage": "operations:prompt:edit",
"prompts:regression": "operations:prompt:regression",
"settings:manage": "operations:settings:view",
};
export function resolveOperationsRole(user: AuthUser | null): OperationsRole {
if (user?.is_system_admin || user?.role_code === "admin") return "admin";
const code = user?.role_code?.toLowerCase() ?? "";
@@ -76,8 +132,27 @@ export function isOperationsActionVisibleForRole(role: OperationsRole, action: O
return ACTION_ROLE_VISIBILITY[action].includes(role);
}
export function useOperationsPermission(permissionCode: string): boolean {
const { user } = useAuth();
return Boolean(user?.is_system_admin || user?.permissions.includes(permissionCode));
}
export function useCanOperationsPage(page: OperationsPageKey): boolean {
return useOperationsPermission(OPERATIONS_PAGE_PERMISSIONS[page]);
}
export function useCanOperationsAction(action: OperationsAction): boolean {
return useOperationsPermission(OPERATIONS_ACTION_PERMISSIONS[action]);
}
export function operationsPagePermission(page: OperationsPageKey): string {
return OPERATIONS_PAGE_PERMISSIONS[page];
}
export function operationsPageFromPath(pathname: string): OperationsPageKey {
const path = pathname.replace(/^\/operations\/?/, "").split("/")[0] ?? "";
const normalized = pathname.replace(/^\/operations\/?/, "");
if (normalized.startsWith("monitoring/")) return "monitoring-detail";
const path = normalized.split("/")[0] ?? "";
if (!path) return "workbench";
if (path === "monitoring") return "monitoring";
if (path in PAGE_ROLE_VISIBILITY) return path as OperationsPageKey;
+4 -33
View File
@@ -1,5 +1,3 @@
import { MODELS, gradeOf, type ModelGrade } from "./modelData";
export type ReportStatus = "待模型团队阅读" | "编辑中" | "已发送业务团队";
export type ReportType = "监控报告" | "诊断报告";
@@ -18,21 +16,8 @@ export type MonitoringReport = {
synced: boolean;
};
function reportType(grade: ModelGrade): ReportType {
return grade === "A" ? "监控报告" : "诊断报告";
}
export const REPORTS: MonitoringReport[] = [
{ reportId: "R1", bank: "江城银行", modelName: "标准A卡", modelId: "JC-STD-001", version: "v2.3", monitorMonth: "2026-07", type: "诊断报告", status: "待模型团队阅读", generatedAt: "2026-08-15 06:12", outputDate: "2026-08-15", unreadDays: 4, synced: false },
{ reportId: "R2", bank: "通汇银行", modelName: "标准A卡", modelId: "TH-STD-001", version: "v2.0", monitorMonth: "2026-07", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-08-15 06:12", outputDate: "2026-08-15", unreadDays: 0, synced: true },
{ reportId: "R3", bank: "华东银行", modelName: "反欺诈评分", modelId: "HD-AFD-001", version: "v2.0", monitorMonth: "2026-07", type: "诊断报告", status: "待模型团队阅读", generatedAt: "2026-08-18 06:16", outputDate: "2026-08-18", unreadDays: 6, synced: false },
{ reportId: "R4", bank: "南岭银行", modelName: "白户A卡", modelId: "NL-BAI-001", version: "v1.5", monitorMonth: "2026-07", type: "诊断报告", status: "编辑中", generatedAt: "2026-08-15 06:12", outputDate: "2026-08-15", unreadDays: 0, synced: true },
{ reportId: "R5", bank: "云岭银行", modelName: "标准A卡", modelId: "YL-STD-001", version: "v2.1", monitorMonth: "2026-07", type: "监控报告", status: "已发送业务团队", generatedAt: "2026-08-20 06:08", outputDate: "2026-08-20", unreadDays: 0, synced: true },
{ reportId: "R6", bank: "华东银行", modelName: "标准A卡", modelId: "HD-STD-001", version: "v2.2", monitorMonth: "2026-07", type: "监控报告", status: "已发送业务团队", generatedAt: "2026-08-18 06:16", outputDate: "2026-08-18", unreadDays: 0, synced: true },
{ reportId: "H1", bank: "江城银行", modelName: "标准A卡", modelId: "JC-STD-001", version: "v2.3", monitorMonth: "2026-06", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-07-15 06:11", outputDate: "2026-07-15", unreadDays: 0, synced: true },
{ reportId: "H2", bank: "南岭银行", modelName: "标准A卡", modelId: "NL-STD-001", version: "v1.6", monitorMonth: "2026-06", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-07-15 06:11", outputDate: "2026-07-15", unreadDays: 0, synced: true },
{ reportId: "H3", bank: "滨海银行", modelName: "大额A卡", modelId: "BH-BIG-001", version: "v1.1", monitorMonth: "2026-05", type: "诊断报告", status: "已发送业务团队", generatedAt: "2026-06-15 06:09", outputDate: "2026-06-15", unreadDays: 0, synced: true },
];
// 报告数据由后端报告接口提供。接口接入前保持为空,不使用本地样例。
export const REPORTS: MonitoringReport[] = [];
export function latestReports(source: MonitoringReport[] = REPORTS): MonitoringReport[] {
const latestMonth = source.reduce((latest, report) => report.monitorMonth > latest ? report.monitorMonth : latest, "");
@@ -40,20 +25,6 @@ export function latestReports(source: MonitoringReport[] = REPORTS): MonitoringR
}
export function createReportForModel(modelId: string): MonitoringReport | null {
const model = MODELS.find((item) => item.modelId === modelId);
if (!model) return null;
return {
reportId: `AUTO-${model.modelId}`,
bank: model.bank,
modelName: model.name,
modelId: model.modelId,
version: model.version,
monitorMonth: "2026-07",
type: reportType(gradeOf(model)),
status: "待模型团队阅读",
generatedAt: "2026-08-15 06:12",
outputDate: "2026-08-15",
unreadDays: 0,
synced: false,
};
void modelId;
return null;
}
@@ -1,5 +1,4 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { REPORTS, type MonitoringReport } from "./reportData";
@@ -8,12 +7,9 @@ type ReportStore = {
updateReport: (reportId: string, updates: Partial<MonitoringReport>) => void;
};
export const useReportStore = create<ReportStore>()(persist((set) => ({
export const useReportStore = create<ReportStore>()((set) => ({
reports: REPORTS,
updateReport: (reportId, updates) => set((state) => ({
reports: state.reports.map((report) => report.reportId === reportId ? { ...report, ...updates } : report),
})),
}), {
name: "a-card-operations-report-mock",
version: 1,
}));
@@ -1,4 +1,4 @@
import { MODELS, categoryName, type ModelCategoryId } from "./modelData";
import type { ModelCategoryId } from "./modelData";
export type WorkflowStage = {
stage: number;
@@ -63,82 +63,23 @@ export const WORKFLOW_STAGES: WorkflowStage[] = [
{ stage: 7, title: "部署上线", businessDuty: "确认后设置预计上线时间", modelDuty: "确认模型正式上线并登记是否为通用模型" },
];
export const WORKFLOWS: WorkflowInstance[] = [
{
workflowId: "F1", title: "南岭银行 · 标准A卡 迭代", bank: "南岭银行", category: "std", modelId: "NL-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-07-28", currentStage: 4, deadline: "2026-08-08", stalledDays: 12,
files: {
2: [{ name: "NL标准A卡_模型设计方案_v2.docx", uploadedBy: "模型团队 李伟", uploadedAt: "2026-08-04", confirmed: true }],
3: [{ name: "开发结果材料_入模变量与效果.xlsx", uploadedBy: "模型团队 李伟", uploadedAt: "2026-08-14", confirmed: false }],
},
},
{
workflowId: "F2", title: "滨海银行 · 新增标准A卡", bank: "滨海银行", category: "std", modelId: "BH-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-06-12", currentStage: 6, plannedTestAt: "2026-07-30",
files: {
2: [{ name: "BH标准A卡_设计方案.docx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-06-20", confirmed: true }],
3: [{ name: "开发结果_变量清单.xlsx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-08", confirmed: true }],
4: [{ name: "新老模型对比数据.xlsx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-15", confirmed: true }],
5: [
{ name: "评审会议纪要_20260722.docx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-23", confirmed: true },
{ name: "最终评审材料.pptx", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-23", confirmed: true },
],
6: [
{ name: "模型测试文件.zip", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-28", confirmed: false },
{ name: "一致性报告.pdf", uploadedBy: "模型团队 王芳", uploadedAt: "2026-07-28", confirmed: false },
],
},
},
{
workflowId: "F3", title: "华东银行 · 反欺诈评分 重构", bank: "华东银行", category: "afd", modelId: "HD-AFD-001",
initiatedBy: "业务团队 陈静", initiatedAt: "2026-08-11", currentStage: 1, files: {},
},
{
workflowId: "H1", title: "华东银行 · 标准A卡 新增(复用通用版)", bank: "华东银行", category: "std", modelId: "HD-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-03-04", currentStage: 8, completedAt: "2026-04-02", reusedModel: "标准A卡通用版 v2.3", plannedTestAt: "2026-03-18", plannedOnlineAt: "2026-04-02", files: {},
},
{
workflowId: "H2", title: "云岭银行 · 标准A卡 新增(复用通用版)", bank: "云岭银行", category: "std", modelId: "YL-STD-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-01-06", currentStage: 8, completedAt: "2026-02-05", reusedModel: "标准A卡通用版 v2.3", plannedTestAt: "2026-01-20", plannedOnlineAt: "2026-02-05", files: {},
},
{
workflowId: "H3", title: "江城银行 · 大额A卡 新增", bank: "江城银行", category: "big", modelId: "JC-BIG-001",
initiatedBy: "业务团队 张明", initiatedAt: "2025-11-12", currentStage: 8, completedAt: "2026-01-08", plannedTestAt: "2025-12-15", plannedOnlineAt: "2026-01-08", files: {},
},
{
workflowId: "H4", title: "南岭银行 · 白户A卡 迭代", bank: "南岭银行", category: "bai", modelId: "NL-BAI-001",
initiatedBy: "业务团队 陈静", initiatedAt: "2026-04-02", currentStage: 8, completedAt: "2026-05-22", plannedTestAt: "2026-04-28", plannedOnlineAt: "2026-05-22", files: {},
},
{
workflowId: "H5", title: "通汇银行 · 白户A卡 新增(复用通用版)", bank: "通汇银行", category: "bai", modelId: "TH-BAI-001",
initiatedBy: "业务团队 张明", initiatedAt: "2026-01-15", currentStage: 8, completedAt: "2026-02-11", reusedModel: "白户A卡通用版 v1.4", plannedTestAt: "2026-02-01", plannedOnlineAt: "2026-02-11", files: {},
},
];
export const WORKFLOWS: WorkflowInstance[] = [];
export const USAGE_RECORDS: UsageRecord[] = [
{ person: "张明", team: "业务团队", logins: 42, requests: 6, reportsRead: 18, reportsDownloaded: 11, lastLoginAt: "2026-08-21" },
{ person: "陈静", team: "业务团队", logins: 9, requests: 1, reportsRead: 4, reportsDownloaded: 1, lastLoginAt: "2026-08-11" },
{ person: "周伟", team: "业务团队", logins: 3, requests: 0, reportsRead: 1, reportsDownloaded: 0, lastLoginAt: "2026-07-30" },
{ person: "李伟", team: "模型团队", logins: 88, requests: 0, reportsRead: 36, reportsDownloaded: 24, lastLoginAt: "2026-08-21" },
{ person: "王芳", team: "模型团队", logins: 76, requests: 0, reportsRead: 31, reportsDownloaded: 19, lastLoginAt: "2026-08-20" },
{ person: "朱瑞", team: "模型团队", logins: 21, requests: 0, reportsRead: 9, reportsDownloaded: 5, lastLoginAt: "2026-08-14" },
{ person: "王芳", team: "管理员", logins: 76, requests: 0, reportsRead: 31, reportsDownloaded: 19, lastLoginAt: "2026-08-20" },
];
export const USAGE_RECORDS: UsageRecord[] = [];
export function workflowDocuments(): KnowledgeDocument[] {
return WORKFLOWS.flatMap((workflow) => Object.entries(workflow.files).flatMap(([stage, files]) => {
const model = MODELS.find((item) => item.modelId === workflow.modelId);
const stageName = WORKFLOW_STAGES.find((item) => item.stage === Number(stage))?.title ?? "未知环节";
return (files ?? []).map((file) => ({
...file,
workflowId: workflow.workflowId,
workflowTitle: workflow.title,
bank: workflow.bank,
modelName: categoryName(workflow.category),
modelVersion: model?.version ?? "—",
modelName: "—",
modelVersion: "—",
modelId: workflow.modelId,
stage: stageName,
commonModel: Boolean(model?.commonModel),
commonModel: false,
}));
}));
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { Card, CardContent } from "~/components/ui/card";
import { OperationsDataBoundary, OperationsDataProvider } from "~/features/operations/OperationsDataContext";
import {
isOperationsPageVisibleForRole,
useCanOperationsPage,
operationsPageFromPath,
useOperationsRole,
} from "~/features/operations/operationsRole";
@@ -15,8 +16,9 @@ export default function OperationsLayout() {
const location = useLocation();
const navigate = useNavigate();
const page = operationsPageFromPath(location.pathname);
const canViewByPermission = useCanOperationsPage(page);
if (!isOperationsPageVisibleForRole(role, page)) {
if (!isOperationsPageVisibleForRole(role, page) || !canViewByPermission) {
return (
<section className="grid h-full place-items-center bg-bg p-6">
<Card className="w-full max-w-xl">
+13 -13
View File
@@ -105,8 +105,8 @@ export const navigation: NavigationItem[] = [
page: "home",
children: [
{ label: "开发工作台", icon: Home, page: "home", activePath: "/workbench", targetPath: "/workbench" },
{ label: "运维工作台", icon: Gauge, page: "operations", activePath: "/operations", targetPath: "/operations", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "平台使用统计", icon: ChartNoAxesColumn, page: "operations", activePath: "/operations/usage", targetPath: "/operations/usage", visibleToOperationsRoles: ["admin"] },
{ label: "运维工作台", icon: Gauge, page: "operations", activePath: "/operations", targetPath: "/operations", permission: "operations:workbench:view", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "平台使用统计", icon: ChartNoAxesColumn, page: "operations", activePath: "/operations/usage", targetPath: "/operations/usage", permission: "operations:usage:view", visibleToOperationsRoles: ["admin"] },
],
},
{ label: "构建脚本", icon: Code, page: "scripts", permission: "script:view" },
@@ -116,9 +116,9 @@ export const navigation: NavigationItem[] = [
icon: Layers3,
page: "operations",
children: [
{ label: "模型大类概览", icon: Layers3, page: "operations", sub: "models", activePath: "/operations/models", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "细分银行概览", icon: Building2, page: "operations", sub: "banks", activePath: "/operations/banks", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "已上线模型详情", icon: ClipboardList, page: "operations", sub: "deployed-models", activePath: "/operations/deployed-models", visibleToOperationsRoles: ["model", "admin"] },
{ label: "模型大类概览", icon: Layers3, page: "operations", sub: "models", activePath: "/operations/models", permission: "operations:model-overview:view", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "细分银行概览", icon: Building2, page: "operations", sub: "banks", activePath: "/operations/banks", permission: "operations:bank-overview:view", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "已上线模型详情", icon: ClipboardList, page: "operations", sub: "deployed-models", activePath: "/operations/deployed-models", permission: "operations:deployed-models:view", visibleToOperationsRoles: ["model", "admin"] },
],
},
{
@@ -126,9 +126,9 @@ export const navigation: NavigationItem[] = [
icon: Activity,
page: "operations",
children: [
{ label: "监控明细", icon: ListFilter, page: "operations", sub: "monitoring", activePath: "/operations/monitoring", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "监控诊断报告", icon: FileText, page: "operations", sub: "reports", activePath: "/operations/reports", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "历史报告汇总", icon: Files, page: "operations", sub: "report-summary", activePath: "/operations/report-summary", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "监控明细", icon: ListFilter, page: "operations", sub: "monitoring", activePath: "/operations/monitoring", permission: "operations:monitoring-overview:view", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "监控诊断报告", icon: FileText, page: "operations", sub: "reports", activePath: "/operations/reports", permission: "operations:report:view", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "历史报告汇总", icon: Files, page: "operations", sub: "report-summary", activePath: "/operations/report-summary", permission: "operations:report-summary:view", visibleToOperationsRoles: ["business", "model", "admin"] },
],
},
{
@@ -136,8 +136,8 @@ export const navigation: NavigationItem[] = [
icon: GitBranch,
page: "operations",
children: [
{ label: "全流程进度", icon: GitBranch, page: "operations", sub: "workflows", activePath: "/operations/workflows", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "文档知识库", icon: BookOpen, page: "operations", sub: "knowledge", activePath: "/operations/knowledge", visibleToOperationsRoles: ["model", "admin"] },
{ label: "全流程进度", icon: GitBranch, page: "operations", sub: "workflows", activePath: "/operations/workflows", permission: "operations:workflow:view", visibleToOperationsRoles: ["business", "model", "admin"] },
{ label: "文档知识库", icon: BookOpen, page: "operations", sub: "knowledge", activePath: "/operations/knowledge", permission: "operations:knowledge:view", visibleToOperationsRoles: ["model", "admin"] },
],
},
{
@@ -148,9 +148,9 @@ export const navigation: NavigationItem[] = [
{ label: "用户管理", icon: Users, page: "system", sub: "users", permission: "system:user:view" },
{ label: "项目管理", icon: Folder, page: "system", sub: "projects", permission: "system:project:view" },
{ label: "角色管理", icon: ShieldCheck, page: "system", sub: "roles", permission: "system:role:view" },
{ label: "监控等级规则", icon: ShieldCheck, page: "operations", sub: "rules", activePath: "/operations/rules", visibleToOperationsRoles: ["model", "admin"] },
{ label: "报告 Prompt 管理", icon: MessageSquareText, page: "operations", sub: "prompts", activePath: "/operations/prompts", visibleToOperationsRoles: ["model", "admin"] },
{ label: "运维系统配置", icon: Wrench, page: "operations", sub: "settings", activePath: "/operations/settings", visibleToOperationsRoles: ["admin"] },
{ label: "监控等级规则", icon: ShieldCheck, page: "operations", sub: "rules", activePath: "/operations/rules", permission: "operations:rules:view", visibleToOperationsRoles: ["model", "admin"] },
{ label: "报告 Prompt 管理", icon: MessageSquareText, page: "operations", sub: "prompts", activePath: "/operations/prompts", permission: "operations:prompt:view", visibleToOperationsRoles: ["model", "admin"] },
{ label: "运维系统配置", icon: Wrench, page: "operations", sub: "settings", activePath: "/operations/settings", permission: "operations:settings:view", visibleToOperationsRoles: ["admin"] },
],
},
];
+21 -2
View File
@@ -83,7 +83,7 @@ function NavRow({
}) {
const itemIcon = <item.icon />;
const childActivePath = (child: NavigationItem) =>
child.activePath ?? pathForPage(child.page);
child.activePath ?? (child.sub ? `${pathForPage(child.page)}/${child.sub}` : pathForPage(child.page));
const childIsActive = (child: NavigationItem) => {
const activePath = childActivePath(child);
return pathname === activePath || Boolean(child.sub && pathname.startsWith(`${activePath}/`));
@@ -208,6 +208,7 @@ function AuthenticatedLayout() {
const setWorkspaceMenuOpen = useUiStore((s) => s.setWorkspaceMenuOpen);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [operationsServiceOnline, setOperationsServiceOnline] = useState(false);
const bindingGeneration = useRef(0);
const can = usePermission;
@@ -245,6 +246,20 @@ function AuthenticatedLayout() {
// 心跳 / cleanup / 切页结束编辑 / 卸载前释放
useEditSessionLifecycle({ activePage });
useEffect(() => {
if (activePage !== "operations") return;
const controller = new AbortController();
void fetch("/api/v1/health", {
credentials: "same-origin",
signal: controller.signal,
})
.then((response) => setOperationsServiceOnline(response.ok))
.catch(() => {
if (!controller.signal.aborted) setOperationsServiceOnline(false);
});
return () => controller.abort();
}, [activePage]);
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
function guardedNavigate(targetPath: string) {
@@ -312,7 +327,7 @@ function AuthenticatedLayout() {
<SidebarInset className="h-screen min-w-0">
<Topbar
pageTitle={pageTitle}
apiOnline={apiOnline}
apiOnline={activePage === "operations" ? operationsServiceOnline : apiOnline}
user={user}
currentWorkspace={effectiveWorkspace}
workspaces={effectiveWorkspaces}
@@ -320,6 +335,10 @@ function AuthenticatedLayout() {
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
onSetCurrentWorkspace={setCurrentWorkspace}
onLogout={logout}
onBack={() => {
if (window.history.length > 1) navigate(-1);
else navigate("/workbench");
}}
/>
<Outlet />
+1 -66
View File
@@ -1,16 +1,3 @@
export type DemoUser = {
userId: string;
userName: string;
username: string;
roleCode: "admin" | "developer";
roleName: string;
};
export type DemoWorkspace = {
workspaceId: string;
workspaceName: string;
};
export function createUuid(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
@@ -40,58 +27,6 @@ export function createUuid(): string {
].join("-");
}
export const demoUsers: DemoUser[] = [
{ userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
{ userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
];
export const demoWorkspaces: DemoWorkspace[] = [
{ workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
{ workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
];
function readStoredContext(): Partial<{
userId: string;
workspaceId: string;
}> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(
window.localStorage.getItem("model-platform-demo-context") ?? "{}",
) as Partial<{ userId: string; workspaceId: string }>;
} catch {
return {};
}
}
const storedContext = readStoredContext();
const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
?? demoUsers[0];
const initialWorkspace = demoWorkspaces.find(
(item) => item.workspaceId === storedContext.workspaceId,
) ?? demoWorkspaces[0];
export const demoContext = {
...initialUser,
...initialWorkspace,
};
export function setDemoContext(input: {
user?: DemoUser;
workspace?: DemoWorkspace;
}): void {
if (input.user) Object.assign(demoContext, input.user);
if (input.workspace) Object.assign(demoContext, input.workspace);
if (typeof window !== "undefined") {
window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
userId: demoContext.userId,
workspaceId: demoContext.workspaceId,
}));
}
}
// API client for the platform backend.
//
// All endpoints that take a workspace context require the caller to
@@ -111,7 +46,7 @@ export type Employee = {
display_name: string;
email: string | null;
status: "active" | "disabled" | "locked";
role_code: "admin" | "developer";
role_code: string;
role_name: string;
created_at: string;
};
+2 -2
View File
@@ -245,7 +245,7 @@ export type WorkspaceBoundApi = {
display_name: string;
email?: string | undefined;
password: string;
role_code?: "admin" | "developer";
role_code?: string;
},
) => Promise<Employee>;
updateEmployee: (
@@ -257,7 +257,7 @@ export type WorkspaceBoundApi = {
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
role_code?: string;
status?: "active" | "disabled" | "locked";
},
) => Promise<Employee>;
@@ -25,7 +25,7 @@ export async function createPlatformEmployee(
display_name: string;
email?: string | undefined;
password: string;
role_code?: "admin" | "developer";
role_code?: string;
},
): Promise<Employee> {
return apiRequest<Employee>(
@@ -39,7 +39,7 @@ export async function updatePlatformEmployee(
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
role_code?: string;
status?: "active" | "disabled" | "locked";
},
): Promise<Employee> {
+51 -44
View File
@@ -1,14 +1,12 @@
import {
MODELS,
monitoringRows,
type ModelCategoryId,
type ModelRecord,
type ModelStatus,
type MonitoringRow,
type RankingStatus,
import type {
ModelCategoryId,
ModelRecord,
ModelStatus,
MonitoringRow,
RankingStatus,
} from "~/features/operations/modelData";
export type OperationsApiMode = "mock" | "api";
export type OperationsApiMode = "api";
export type OperationsModelListParams = {
workspaceId?: string;
@@ -49,6 +47,41 @@ export type MonthlyMonitoringResultDto = {
secondary_hits_6m: number;
};
export type WorkbenchTone = "normal" | "warning" | "danger" | "primary";
export type OperationsWorkbenchKpi = {
key: string;
label: string;
value: number;
detail: string;
target: string;
tone: WorkbenchTone;
};
export type OperationsWorkbenchItem = {
id: string;
tone: WorkbenchTone;
title: string;
detail: string;
action_label: string;
target: string;
};
export type OperationsWorkbenchActivity = {
occurred_at: string;
actor: string;
text: string;
};
export type OperationsWorkbenchDto = {
role: "admin" | "model_team" | "business_team";
alerts: string[];
kpis: OperationsWorkbenchKpi[];
todos: OperationsWorkbenchItem[];
watches: OperationsWorkbenchItem[];
activities: OperationsWorkbenchActivity[];
};
type ApiEnvelope<T> = {
data: T;
meta?: Record<string, unknown>;
@@ -66,8 +99,7 @@ export class OperationsApiError extends Error {
}
}
const configuredMode = import.meta.env.VITE_OPERATIONS_API_MODE;
export const operationsApiMode: OperationsApiMode = configuredMode === "api" ? "api" : "mock";
export const operationsApiMode: OperationsApiMode = "api";
const API_BASE = "/api/v1/operations";
function requireWorkspaceId(workspaceId?: string): string {
@@ -117,28 +149,7 @@ function toModelRecord(dto: OperationsModelDto): ModelRecord {
};
}
function mockDelay(signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(resolve, 160);
signal?.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Request aborted", "AbortError"));
}, { once: true });
});
}
export async function listOperationsModels(params: OperationsModelListParams = {}): Promise<ModelRecord[]> {
if (operationsApiMode === "mock") {
await mockDelay(params.signal);
const keyword = params.keyword?.trim().toLowerCase();
return MODELS
.filter((model) => !params.bank || model.bank === params.bank)
.filter((model) => !params.category || model.category === params.category)
.filter((model) => !params.status || model.status === params.status)
.filter((model) => !keyword || `${model.bank} ${model.name} ${model.modelId} ${model.version}`.toLowerCase().includes(keyword))
.map((model) => ({ ...model }));
}
const query = new URLSearchParams();
query.set("workspace_id", requireWorkspaceId(params.workspaceId));
if (params.bank) query.set("bank", params.bank);
@@ -155,12 +166,6 @@ export async function getOperationsModel(
workspaceId?: string,
signal?: AbortSignal,
): Promise<ModelRecord> {
if (operationsApiMode === "mock") {
await mockDelay(signal);
const model = MODELS.find((item) => item.modelId === modelId);
if (!model) throw new OperationsApiError("模型不存在", 404, "MODEL_NOT_FOUND");
return { ...model };
}
const query = new URLSearchParams({ workspace_id: requireWorkspaceId(workspaceId) });
const data = await request<OperationsModelDto>(`/models/${encodeURIComponent(modelId)}?${query.toString()}`, { signal });
return toModelRecord(data);
@@ -172,12 +177,6 @@ export async function getMonthlyMonitoringResult(
workspaceId?: string,
signal?: AbortSignal,
): Promise<MonitoringRow> {
if (operationsApiMode === "mock") {
await mockDelay(signal);
const result = monitoringRows(MODELS).find((item) => item.modelId === modelId && item.monitorMonth === month);
if (!result) throw new OperationsApiError("该月份暂无监控结果", 404, "MONITOR_RESULT_NOT_FOUND");
return { ...result };
}
const model = await getOperationsModel(modelId, workspaceId, signal);
const query = new URLSearchParams({
month,
@@ -197,3 +196,11 @@ export async function getMonthlyMonitoringResult(
secondaryHits: data.secondary_hits_6m,
};
}
export async function getOperationsWorkbench(
workspaceId?: string,
signal?: AbortSignal,
): Promise<OperationsWorkbenchDto> {
const query = new URLSearchParams({ workspace_id: requireWorkspaceId(workspaceId) });
return request<OperationsWorkbenchDto>(`/workbench?${query.toString()}`, { signal });
}