feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
This commit is contained in:
@@ -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
@@ -16,10 +16,11 @@
|
||||
仅将库名切换为 `model_operations`。
|
||||
- `PLATFORM_READ_DATABASE_URL` 与 `DEPLOY_DATABASE_URL` 使用独立只读
|
||||
Session;MySQL 事务会显式执行 `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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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. 最后再扩展工作台、监控明细、报告和流程等页面的接口。
|
||||
|
||||
## 六、当前不可对外承诺的内容
|
||||
|
||||
在非空数据和三角色验收完成前,不建议对外表述为“首条真实接口链路已完整跑通”。更准确的表述是:
|
||||
|
||||
> 已完成首条真实接口链路的工程实现、接口注册、前端接入和空数据验证;待模型方提供最小非空数据后完成业务数据联调验收。
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">读取排序性、KS、PSI、IV、CSI。本平台不重算指标,只读取模型平台计算结果;共享表名和字段映射待接口设计阶段确定。</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 }> = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user