一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
1902 lines
90 KiB
Python
1902 lines
90 KiB
Python
"""统一登录门户(**调试/联调工具**,不是产品前端)。
|
||
|
||
> ⚠️ **定位说明(2026-09-13)**:正式前端已由组员交付并合入主干,位于
|
||
> `app/static/portal/`,通过 **`/portal/`** 访问(访客 / 客户 / 员工 / 风控四套页面;
|
||
> 端点表见 `app/static/portal/common/api-client.js`)。
|
||
> **本文件不是产品的一部分**,它是平台侧保留的**跨角色快速联调工具**:
|
||
> 一个页面里用五个演示账号登录、直接点各模块接口、看原始响应与错误码。
|
||
> 涉及具体页面与交互的工作请改正式前端,不要再往这里加功能。
|
||
|
||
---
|
||
|
||
一个登录入口,按角色分流到不同的工作台。
|
||
|
||
**和 `tools/chat_console.py` 的区别**:那个控制台没有登录、身份写死在命令行里;
|
||
本门户**走真实的 `POST /api/v1/auth/tokens`**,令牌只存活在本进程,浏览器拿不到,
|
||
然后按登录者的**真实角色**决定进哪个界面 —— 也就是照着"客户 / 员工 / 管理员"三种
|
||
使用者的实际路径走一遍,而不是逐个接口点着测。
|
||
|
||
| 登录者的角色 | 进入的界面 | 主要能力 |
|
||
|---|---|---|
|
||
| `customer` | **客服** | 与客服 Agent 对话(真实受理 + 进程内驱动 Worker)、转人工、查看并确认自己的画像候选 |
|
||
| `risk_operator` / `operator` | **员工工作台** | 风控总览、预警列表与详情、预警扫描、确认/升级/解决等处置、生成日报 |
|
||
| `admin` / `super_admin` | **权限界面** | 角色清单、每个角色的权限明细、按用户查角色、审计流水、客服转人工工单、知识库清单 |
|
||
| `advisor` | **投顾工作台** | 当前投资目标、已发布方案、组合分析 |
|
||
|
||
角色从 `IdentityService.resolve()` 现查(不是从令牌里读)—— 所以改了库里角色,重登即生效。
|
||
|
||
跑法:
|
||
|
||
```powershell
|
||
D:\\conda\\envs\\jr_py313\\python.exe tools\\portal.py
|
||
# 浏览器打开 http://127.0.0.1:8101
|
||
```
|
||
|
||
**前置**:
|
||
|
||
1. MySQL / Redis 可用(平台启动时会连);
|
||
2. 演示账号能登录:先 `python tools/seed_test_rbac.py`,再 `python tools/set_user_password.py`;
|
||
3. **客服对话前请停掉常驻 Worker**(`python -m app.worker`)—— 本门户自己驱动这一条 run,
|
||
常驻 Worker 会与它抢 `agent_run` 队列,表现为页面一直转圈。
|
||
|
||
**权限问题的表现是设计如此**:某个按钮点了返回 403,说明这个角色的权限**本来就不够**
|
||
(例如客户点风控、员工点权限管理)。平台一律 fail closed,门户把 403 原样显示出来,
|
||
不做任何前端隐藏式"代为授权"。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import uvicorn
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.responses import HTMLResponse, JSONResponse
|
||
|
||
sys.stdout.reconfigure(errors="replace")
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
CUSTOMER_AGENT = "customer_service"
|
||
|
||
DEMO_ACCOUNTS: tuple[tuple[str, str, str], ...] = (
|
||
("cust_t", "123456", "客户"),
|
||
("risk_t", "666666", "员工(风控专员)"),
|
||
("admin_t", "88888888", "管理员"),
|
||
("advisor_t", "abc12345", "投资顾问"),
|
||
("offsite_t", "offsite123", "员工(运营专员)"),
|
||
)
|
||
|
||
#: 角色 → 界面。按优先级从高到低匹配,多角色者进权限最高的那个界面。
|
||
VIEW_BY_ROLE: tuple[tuple[str, str], ...] = (
|
||
("super_admin", "admin"),
|
||
("admin", "admin"),
|
||
("risk_operator", "staff"),
|
||
("operator", "offsite"), # 运营走场外基金这条线,不是风控那个台
|
||
("advisor", "advisor"),
|
||
("customer", "customer"),
|
||
)
|
||
|
||
VIEW_LABELS = {
|
||
"customer": "客服",
|
||
"staff": "风控工作台",
|
||
"offsite": "运营工作台",
|
||
"admin": "权限管理",
|
||
"advisor": "投顾工作台",
|
||
"unknown": "未分配界面",
|
||
}
|
||
|
||
|
||
def view_for(roles: tuple[str, ...] | list[str]) -> str:
|
||
for role, view in VIEW_BY_ROLE:
|
||
if role in roles:
|
||
return view
|
||
return "unknown"
|
||
|
||
|
||
class Platform:
|
||
"""共享的平台连接:一个 app、一个 client、一个 lifespan,所有会话共用。
|
||
|
||
分开的理由:每个会话各自 `create_app()` 会把平台实例化好几遍(连接池、后台任务都翻倍);
|
||
而"多个浏览器标签用不同身份"又是演示时的刚需 —— 于是把**连接**共享、把**令牌**按会话分开。
|
||
"""
|
||
|
||
def __init__(self, base_url: str | None) -> None:
|
||
self.base_url = base_url
|
||
self.application: FastAPI | None = None
|
||
self._client: httpx.AsyncClient | None = None
|
||
self._lifespan: Any = None
|
||
self.environment: dict[str, Any] = {}
|
||
|
||
async def client(self) -> httpx.AsyncClient:
|
||
if self._client is not None:
|
||
return self._client
|
||
if self.base_url:
|
||
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0)
|
||
self.environment = {"mode": "外部服务", "target": self.base_url}
|
||
return self._client
|
||
|
||
from app.core.config import get_settings
|
||
from app.main import create_app
|
||
|
||
application = create_app()
|
||
self.application = application
|
||
self._lifespan = application.router.lifespan_context(application)
|
||
await self._lifespan.__aenter__()
|
||
self._client = httpx.AsyncClient(
|
||
transport=httpx.ASGITransport(app=application), base_url="http://platform", timeout=120.0
|
||
)
|
||
dsn = get_settings().mysql_dsn
|
||
self.environment = {"mode": "进程内", "mysql": dsn.split("@")[-1] if "@" in dsn else dsn}
|
||
return self._client
|
||
|
||
async def aclose(self) -> None:
|
||
if self._client is not None:
|
||
await self._client.aclose()
|
||
self._client = None
|
||
if self._lifespan is not None:
|
||
await self._lifespan.__aexit__(None, None, None)
|
||
self._lifespan = None
|
||
|
||
async def request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
token: str | None,
|
||
query: dict[str, Any] | None = None,
|
||
body: Any = None,
|
||
) -> dict[str, Any]:
|
||
if not token:
|
||
return {"status": 401, "body": {"error": {"code": "NOT_LOGGED_IN", "message": "请先登录"}}}
|
||
client = await self.client()
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
# 幂等头:平台的写接口要求 `Idempotency-Key`(16-128 位 ASCII),
|
||
# 漏了会被 `AGENT_INPUT_INVALID: 必须提供 16-128 位 ASCII Idempotency-Key` 拒绝。
|
||
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
|
||
headers["Idempotency-Key"] = uuid.uuid4().hex
|
||
try:
|
||
response = await client.request(
|
||
method.upper(),
|
||
path,
|
||
params=query or None,
|
||
json=body,
|
||
headers=headers,
|
||
)
|
||
except Exception as exc:
|
||
return {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
|
||
return {"status": response.status_code, "body": _json(response)}
|
||
|
||
|
||
class Session:
|
||
"""一次登录会话:只持有令牌与身份。"""
|
||
|
||
def __init__(self, username: str, user_id: str, token: str) -> None:
|
||
self.username = username
|
||
self.user_id = user_id
|
||
self.token = token
|
||
self.roles: tuple[str, ...] = ()
|
||
self.permissions: tuple[str, ...] = ()
|
||
|
||
def as_dict(self, environment: dict[str, Any]) -> dict[str, Any]:
|
||
view = view_for(self.roles)
|
||
return {
|
||
"username": self.username,
|
||
"user_id": self.user_id,
|
||
"roles": list(self.roles),
|
||
"permissions": list(self.permissions),
|
||
"view": view,
|
||
"view_label": VIEW_LABELS[view],
|
||
"logged_in": True,
|
||
"environment": environment,
|
||
}
|
||
|
||
|
||
class Portal:
|
||
"""多会话门户:`X-Session` 头区分标签页,令牌始终留在服务端。"""
|
||
|
||
def __init__(self, base_url: str | None) -> None:
|
||
self.platform = Platform(base_url)
|
||
self.sessions: dict[str, Session] = {}
|
||
self.error: str | None = None
|
||
|
||
async def login(self, session_id: str, username: str, password: str) -> dict[str, Any]:
|
||
client = await self.platform.client()
|
||
response = await client.post(
|
||
"/api/v1/auth/tokens", json={"username": username, "password": password}
|
||
)
|
||
payload = _json(response)
|
||
if response.status_code != 200:
|
||
return {"ok": False, "status": response.status_code, "body": payload}
|
||
|
||
data = (payload.get("data") or {}) if isinstance(payload, dict) else {}
|
||
token = data.get("access_token") or data.get("token")
|
||
if not token:
|
||
return {"ok": False, "status": response.status_code, "body": payload}
|
||
|
||
session = Session(username, str(data.get("user_id") or _sub_of(token) or ""), token)
|
||
await self._resolve(session)
|
||
self.sessions[session_id] = session
|
||
return {"ok": True, "me": session.as_dict(self.platform.environment)}
|
||
|
||
async def _resolve(self, session: Session) -> None:
|
||
"""角色与权限现查库 —— 与平台每请求解析的口径一致(令牌里只有 sub)。"""
|
||
from app.core.contracts import RequestContext
|
||
from app.service.identity_service import IdentityService
|
||
|
||
context = await IdentityService().resolve(
|
||
RequestContext(user_id=session.user_id, trace_id=str(uuid.uuid4()))
|
||
)
|
||
session.roles = tuple(context.roles)
|
||
session.permissions = tuple(sorted(context.permissions))
|
||
|
||
def logout(self, session_id: str) -> None:
|
||
self.sessions.pop(session_id, None)
|
||
|
||
def me(self, session_id: str) -> dict[str, Any]:
|
||
session = self.sessions.get(session_id)
|
||
data = (
|
||
session.as_dict(self.platform.environment)
|
||
if session
|
||
else {"logged_in": False, "environment": self.platform.environment}
|
||
)
|
||
data["accounts"] = [
|
||
{"username": u, "password": p, "label": label} for u, p, label in DEMO_ACCOUNTS
|
||
]
|
||
data["startup_error"] = self.error
|
||
return data
|
||
|
||
async def call(
|
||
self,
|
||
session_id: str,
|
||
method: str,
|
||
path: str,
|
||
query: dict[str, Any] | None = None,
|
||
body: Any = None,
|
||
) -> dict[str, Any]:
|
||
session = self.sessions.get(session_id)
|
||
return await self.platform.request(
|
||
method, path, token=session.token if session else None, query=query, body=body
|
||
)
|
||
|
||
async def chat(self, session_id: str, message: str, chat_session: str) -> dict[str, Any]:
|
||
"""客服对话:真实受理 → 本进程驱动这一条 run → 取结果。"""
|
||
from app.worker.runtime import WorkerRuntime
|
||
|
||
accepted = await self.call(
|
||
session_id,
|
||
"POST",
|
||
"/api/v1/agent-runs",
|
||
body={
|
||
"agent_type": CUSTOMER_AGENT,
|
||
"message": message,
|
||
"session_id": chat_session,
|
||
"idempotency_key": uuid.uuid4().hex,
|
||
},
|
||
)
|
||
if accepted["status"] != 202:
|
||
return {"ok": False, "status": accepted["status"], "body": accepted["body"]}
|
||
|
||
run_id = ((accepted["body"] or {}).get("data") or {}).get("run_id")
|
||
if not run_id:
|
||
return {"ok": False, "status": accepted["status"], "body": accepted["body"]}
|
||
|
||
await WorkerRuntime().execute(run_id)
|
||
detail = await self.call(session_id, "GET", f"/api/v1/agent-runs/{run_id}")
|
||
data = (detail["body"] or {}).get("data") or {}
|
||
result = data.get("result") or {}
|
||
raw_intent = result.get("intent")
|
||
intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent
|
||
return {
|
||
"ok": True,
|
||
"run_id": run_id,
|
||
"status": data.get("status"),
|
||
"answer": str(result.get("content") or ""),
|
||
"intent": intent or "",
|
||
"transfer": bool(result.get("transfer_required")),
|
||
"transfer_reason": result.get("transfer_reason") or "",
|
||
}
|
||
|
||
|
||
def _json(response: httpx.Response) -> Any:
|
||
try:
|
||
return response.json()
|
||
except Exception:
|
||
return {"_raw": response.text[:3000]}
|
||
|
||
|
||
def _sub_of(token: str) -> str | None:
|
||
import jwt
|
||
|
||
try:
|
||
return str(jwt.decode(token, options={"verify_signature": False}).get("sub"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def build_portal(state: Portal) -> FastAPI:
|
||
# 注意:`Request` 必须在**模块顶层**导入。本文件启用了
|
||
# `from __future__ import annotations`,注解在此处是字符串,FastAPI 解析
|
||
# `request: Request` 时只在模块全局里找类型名 —— 写成函数内 import 会被
|
||
# 当成"必填 query 参数 request",所有请求直接 422,且报错完全看不出是这个原因。
|
||
portal = FastAPI(title="统一登录门户")
|
||
|
||
def sid(request: Request) -> str:
|
||
return request.headers.get("X-Session") or "default"
|
||
|
||
@portal.get("/", response_class=HTMLResponse)
|
||
async def index() -> HTMLResponse:
|
||
return HTMLResponse(PAGE)
|
||
|
||
@portal.get("/api/me")
|
||
async def me(request: Request) -> JSONResponse:
|
||
return JSONResponse(state.me(sid(request)))
|
||
|
||
@portal.post("/api/login")
|
||
async def login(request: Request, payload: dict[str, Any]) -> JSONResponse:
|
||
try:
|
||
result = await state.login(
|
||
sid(request), str(payload.get("username") or ""), str(payload.get("password") or "")
|
||
)
|
||
state.error = None
|
||
except Exception as exc:
|
||
state.error = f"{type(exc).__name__}: {exc}"
|
||
result = {"ok": False, "status": 0, "body": {"error": {"message": state.error}}}
|
||
return JSONResponse(result)
|
||
|
||
@portal.post("/api/logout")
|
||
async def logout(request: Request) -> JSONResponse:
|
||
state.logout(sid(request))
|
||
return JSONResponse({"ok": True})
|
||
|
||
@portal.post("/api/call")
|
||
async def call(request: Request, payload: dict[str, Any]) -> JSONResponse:
|
||
try:
|
||
result = await state.call(
|
||
sid(request),
|
||
str(payload.get("method") or "GET"),
|
||
str(payload.get("path") or "/"),
|
||
payload.get("query"),
|
||
payload.get("body"),
|
||
)
|
||
except Exception as exc:
|
||
result = {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
|
||
return JSONResponse(result)
|
||
|
||
@portal.post("/api/chat")
|
||
async def chat(request: Request, payload: dict[str, Any]) -> JSONResponse:
|
||
message = str(payload.get("message") or "").strip()
|
||
chat_session = str(payload.get("session_id") or f"portal-{uuid.uuid4().hex[:8]}")
|
||
if not message:
|
||
return JSONResponse({"ok": False, "body": {"error": {"message": "请输入内容"}}})
|
||
try:
|
||
return JSONResponse(await state.chat(sid(request), message, chat_session))
|
||
except Exception as exc:
|
||
return JSONResponse(
|
||
{"ok": False, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
|
||
)
|
||
|
||
return portal
|
||
|
||
|
||
PAGE = r"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>基金智能服务平台</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||
background: #eef1f6; color: #1f2d3d; }
|
||
/* 登录 */
|
||
#login { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||
.card { background: #fff; padding: 30px 34px; border-radius: 10px; width: 380px;
|
||
box-shadow: 0 6px 26px rgba(31,45,61,.12); }
|
||
.card h1 { margin: 0 0 4px; font-size: 19px; }
|
||
.card p.sub { margin: 0 0 20px; color: #6b7c93; font-size: 12.5px; }
|
||
.card label { display: block; font-size: 12.5px; color: #5a6b7f; margin: 12px 0 4px; }
|
||
.card input { width: 100%; padding: 9px 11px; border: 1px solid #c9d2dd; border-radius: 5px; font: inherit; }
|
||
.card button { width: 100%; margin-top: 18px; padding: 10px; font: inherit; font-size: 14.5px;
|
||
background: #1f6feb; color: #fff; border: 0; border-radius: 5px; cursor: pointer; }
|
||
.card button:hover { background: #1558c0; }
|
||
.quick { margin-top: 18px; border-top: 1px solid #eef2f7; padding-top: 14px; }
|
||
.quick span { font-size: 12px; color: #6b7c93; }
|
||
.quick button { width: auto; display: inline-block; margin: 6px 6px 0 0; padding: 5px 10px;
|
||
font-size: 12.5px; background: #f2f5f9; color: #33445c; border: 1px solid #d8e0ea; }
|
||
.quick button:hover { background: #e6edf6; }
|
||
.err { margin-top: 14px; padding: 9px 11px; background: #fdecea; color: #a8342a;
|
||
border-radius: 5px; font-size: 12.5px; white-space: pre-wrap; }
|
||
/* 主界面 */
|
||
#app { display: none; min-height: 100vh; flex-direction: column; }
|
||
header { background: #1f2d3d; color: #fff; padding: 11px 18px; display: flex; align-items: center;
|
||
gap: 14px; flex-wrap: wrap; font-size: 13.5px; }
|
||
header b { font-size: 15px; }
|
||
header .who { color: #cfe0f5; }
|
||
header .role { background: #2d4059; padding: 3px 9px; border-radius: 4px; font-size: 12px; }
|
||
header .env { color: #8fa6c0; font-size: 11.5px; font-family: Consolas, monospace; }
|
||
header .spacer { flex: 1; }
|
||
header button { font: inherit; padding: 4px 11px; border: 1px solid #44597a; background: #26374d;
|
||
color: #dfe9f5; border-radius: 4px; cursor: pointer; }
|
||
header button:hover { background: #33475f; }
|
||
nav { background: #fff; border-bottom: 1px solid #dde3ea; padding: 0 18px; display: flex; gap: 4px; }
|
||
nav button { font: inherit; font-size: 13.5px; padding: 11px 15px; border: 0; background: none;
|
||
color: #5a6b7f; cursor: pointer; border-bottom: 2px solid transparent; }
|
||
nav button.active { color: #1f6feb; border-bottom-color: #1f6feb; font-weight: 600; }
|
||
main { flex: 1; padding: 18px; max-width: 1180px; width: 100%; margin: 0 auto; }
|
||
.panel { background: #fff; border-radius: 8px; padding: 18px 20px;
|
||
box-shadow: 0 1px 3px rgba(31,45,61,.07); margin-bottom: 16px; }
|
||
.panel h2 { margin: 0 0 4px; font-size: 16px; }
|
||
.panel p.hint { margin: 0 0 14px; color: #6b7c93; font-size: 12.5px; line-height: 1.7; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; }
|
||
.stat { background: #f7f9fc; border: 1px solid #e6ecf3; border-radius: 6px; padding: 12px 14px; }
|
||
.stat .n { font-size: 22px; font-weight: 600; color: #1f2d3d; }
|
||
.stat .l { font-size: 12px; color: #6b7c93; margin-top: 2px; }
|
||
button.act { font: inherit; padding: 5px 11px; border: 1px solid #c9d2dd; background: #fff;
|
||
border-radius: 4px; cursor: pointer; font-size: 12.5px; }
|
||
button.act:hover { background: #eef2f7; }
|
||
button.act.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||
button.act.primary:hover { background: #1558c0; }
|
||
button.act.warn { background: #fff8e6; border-color: #e0c080; color: #8a5a00; }
|
||
table { border-collapse: collapse; width: 100%; font-size: 12.8px; }
|
||
th, td { border-bottom: 1px solid #e9eef4; padding: 7px 9px; text-align: left; vertical-align: top; }
|
||
th { background: #f7f9fc; color: #5a6b7f; font-weight: 600; }
|
||
tr:hover td { background: #fafcfe; }
|
||
code { font-family: Consolas, monospace; font-size: 12px; background: #f2f5f9; padding: 1px 5px;
|
||
border-radius: 3px; }
|
||
pre { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px; overflow: auto;
|
||
font-size: 12.4px; max-height: 320px; line-height: 1.5; }
|
||
.pill { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11.5px;
|
||
background: #e8eef6; color: #44536a; margin: 2px 3px 2px 0; }
|
||
.pill.hot { background: #fdecea; color: #a8342a; }
|
||
.pill.ok { background: #e7f6ec; color: #1a7f37; }
|
||
.msg { display: flex; margin-bottom: 10px; }
|
||
.msg.me { justify-content: flex-end; }
|
||
.bubble { max-width: 74%; padding: 10px 13px; border-radius: 9px; background: #fff;
|
||
border: 1px solid #e2e8f0; white-space: pre-wrap; line-height: 1.65; font-size: 13.5px; }
|
||
.msg.me .bubble { background: #1f6feb; color: #fff; border-color: #1f6feb; }
|
||
.tag { display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 3px;
|
||
font-size: 11px; background: #e8eef6; color: #44536a; }
|
||
.tag.warn { background: #fff8e6; color: #8a5a00; }
|
||
.chatlog { height: 380px; overflow: auto; padding: 4px; background: #f7f9fc; border-radius: 6px; }
|
||
.composer { display: flex; gap: 8px; margin-top: 10px; }
|
||
.composer input { flex: 1; padding: 10px 12px; border: 1px solid #c9d2dd; border-radius: 5px; font: inherit; }
|
||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||
input.q { padding: 6px 9px; border: 1px solid #c9d2dd; border-radius: 4px; font: inherit; font-size: 13px; }
|
||
.muted { color: #8fa0b5; font-size: 12.5px; }
|
||
.hidden { display: none; }
|
||
/* 表格:行高固定、超长省略(17- 文档要求) */
|
||
table.fixed { table-layout: fixed; }
|
||
table.fixed td, table.fixed th { height: 30px; line-height: 30px; padding: 0 9px;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
table.fixed td.wrap { white-space: normal; line-height: 1.5; height: auto; padding: 6px 9px; }
|
||
/* 筛选栏 */
|
||
.filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end;
|
||
background: #f7f9fc; border: 1px solid #e6ecf3; border-radius: 6px;
|
||
padding: 10px 12px; margin-bottom: 12px; }
|
||
.filters label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #5a6b7f; }
|
||
.filters input, .filters select { padding: 5px 8px; border: 1px solid #c9d2dd;
|
||
border-radius: 4px; font: inherit; font-size: 13px; min-width: 120px; }
|
||
/* 分页 */
|
||
.pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12.5px;
|
||
color: #5a6b7f; }
|
||
/* 风险等级徽章 */
|
||
.lv { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11.5px; }
|
||
.lv-高, .lv-high { background: #fdecea; color: #a8342a; }
|
||
.lv-中, .lv-medium { background: #fff4e5; color: #8a5a00; }
|
||
.lv-低, .lv-low { background: #e8eef6; color: #44536a; }
|
||
/* Toast */
|
||
#toasts { position: fixed; right: 18px; bottom: 18px; z-index: 9999;
|
||
display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
||
.toast { min-width: 240px; max-width: 420px; padding: 10px 14px; border-radius: 6px;
|
||
box-shadow: 0 4px 16px rgba(31,45,61,.18); font-size: 13px; line-height: 1.6;
|
||
background: #fff; border-left: 4px solid #1f6feb; white-space: pre-wrap; }
|
||
.toast.ok { border-left-color: #1a7f37; }
|
||
.toast.bad { border-left-color: #c0392b; }
|
||
.toast.warn { border-left-color: #e0a800; }
|
||
.toast b { display: block; margin-bottom: 2px; }
|
||
/* 模态框 */
|
||
#modal-back { position: fixed; inset: 0; background: rgba(15,23,32,.45); z-index: 9998;
|
||
display: none; align-items: center; justify-content: center; padding: 20px; }
|
||
#modal-back.on { display: flex; }
|
||
.modal { background: #fff; border-radius: 8px; width: min(860px, 100%);
|
||
max-height: 88vh; display: flex; flex-direction: column;
|
||
box-shadow: 0 12px 40px rgba(15,23,32,.3); }
|
||
.modal header { background: #fff; color: #1f2d3d; border-bottom: 1px solid #e6ecf3;
|
||
padding: 13px 18px; font-size: 15px; font-weight: 600; display: flex;
|
||
align-items: center; }
|
||
.modal .body { padding: 16px 18px; overflow: auto; }
|
||
.modal .foot { padding: 12px 18px; border-top: 1px solid #e6ecf3; display: flex;
|
||
gap: 8px; justify-content: flex-end; }
|
||
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; font-size: 13px; }
|
||
.kv dt { color: #6b7c93; }
|
||
.kv dd { margin: 0; word-break: break-all; }
|
||
.modal textarea { width: 100%; min-height: 88px; padding: 8px; border: 1px solid #c9d2dd;
|
||
border-radius: 4px; font: inherit; font-size: 13px; resize: vertical; }
|
||
.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
||
.tabs button { padding: 5px 11px; font-size: 12.5px; border: 1px solid #c9d2dd;
|
||
background: #fff; border-radius: 14px; cursor: pointer; }
|
||
.tabs button.on { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||
.stream { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px;
|
||
font-family: Consolas, monospace; font-size: 12.5px; white-space: pre-wrap;
|
||
max-height: 300px; overflow: auto; line-height: 1.6; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="login">
|
||
<div class="card">
|
||
<h1>基金智能服务平台</h1>
|
||
<p class="sub">请使用账号登录,系统会按你的角色进入对应界面。</p>
|
||
<label>用户名</label>
|
||
<input id="u" autocomplete="username" placeholder="例如 cust_t">
|
||
<label>密码</label>
|
||
<input id="p" type="password" autocomplete="current-password" placeholder="演示口令">
|
||
<button onclick="doLogin()">登录</button>
|
||
<div class="quick">
|
||
<span>演示账号(点击填入):</span><br>
|
||
<span id="quick"></span>
|
||
</div>
|
||
<div id="login-err" class="err hidden"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toasts"></div>
|
||
<div id="modal-back" onclick="if(event.target===this)closeModal()">
|
||
<div class="modal">
|
||
<header><span id="modal-title">标题</span></header>
|
||
<div class="body" id="modal-body"></div>
|
||
<div class="foot" id="modal-foot"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="app">
|
||
<header>
|
||
<b>基金智能服务平台</b>
|
||
<span class="who" id="who"></span>
|
||
<span class="role" id="role"></span>
|
||
<span class="spacer"></span>
|
||
<span class="env" id="env"></span>
|
||
<span class="env" id="build" title="页面构建时间:刷新后这个值会变,可确认加载的不是缓存旧页"></span>
|
||
<button onclick="doLogout()">退出登录</button>
|
||
</header>
|
||
<nav id="nav"></nav>
|
||
<main id="main"></main>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
const pretty = (o) => { try { return JSON.stringify(o, null, 2); } catch { return String(o); } };
|
||
|
||
let ME = null;
|
||
// 客服会话:必须用 C001 `POST /api/v1/conversations` 真实创建后再用 ——
|
||
// `agent-runs` 的 session_id 不会替你建会话,转人工时后端会查 `conversation` 表,
|
||
// 拿一个自己编的 id 去调就会得到 404「会话不存在」。
|
||
let CONV = null;
|
||
// 门户会话号:每个标签页一个,存在 sessionStorage;令牌本身始终留在服务端 ——
|
||
// 于是可以同时开两个窗口分别用客户与管理员身份,互不干扰。
|
||
let SID = sessionStorage.getItem('portal-sid');
|
||
if (!SID) { SID = "s-" + Math.random().toString(16).slice(2, 12); sessionStorage.setItem('portal-sid', SID); }
|
||
const HEAD = () => ({ 'Content-Type': 'application/json', 'X-Session': SID });
|
||
|
||
async function jpost(url, body) {
|
||
const r = await fetch(url, { method: 'POST', headers: HEAD(), body: JSON.stringify(body ?? {}) });
|
||
return r.json();
|
||
}
|
||
|
||
/* ---------------- 通用交互组件 ----------------
|
||
17- 文档明确要求:不使用浏览器原生 alert/prompt 作为正式交互方案,
|
||
不能只用控制台日志代替用户提示。这里用 Toast + 模态框替代。 */
|
||
|
||
const PAGE_BUILD = "__BUILD__";
|
||
|
||
function toast(text, kind) {
|
||
const box = $('toasts');
|
||
if (!box) { console.error('[portal] #toasts 缺失,退回原生提示'); (kind === 'bad' ? console.error : console.log)(text); return; }
|
||
const el = document.createElement('div');
|
||
el.className = 'toast ' + (kind || '');
|
||
el.textContent = text;
|
||
box.appendChild(el);
|
||
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 3200);
|
||
setTimeout(() => el.remove(), 3600);
|
||
}
|
||
|
||
function ok(text) { toast(text, 'ok'); }
|
||
function bad(text) { toast(text, 'bad'); }
|
||
function warn(text) { toast(text, 'warn'); }
|
||
|
||
function openModal(title, bodyHtml, footHtml) {
|
||
const back = $('modal-back');
|
||
if (!back) {
|
||
// 兜底:容器缺失(多半是浏览器缓存了旧页面)时不要静默失败
|
||
console.error('[portal] #modal-back 缺失:页面可能是旧版本,请硬刷新(Ctrl+F5)');
|
||
window.alert(String(title) + '\n\n页面组件缺失,请硬刷新后再试(Ctrl+F5)');
|
||
return false;
|
||
}
|
||
$('modal-title').textContent = title;
|
||
$('modal-body').innerHTML = bodyHtml;
|
||
$('modal-foot').innerHTML = footHtml || '<button class="act" onclick="closeModal()">关闭</button>';
|
||
back.classList.add('on');
|
||
return true;
|
||
}
|
||
function closeModal() { $('modal-back').classList.remove('on'); }
|
||
|
||
/** 替代 confirm:返回 Promise<boolean>。 */
|
||
function askConfirm(title, text, okLabel) {
|
||
return new Promise((resolve) => {
|
||
const opened = openModal(title, `<p style="font-size:13.5px;line-height:1.8">${esc(text)}</p>`,
|
||
`<button class="act" onclick="closeModal();window.__ask(false)">取消</button>
|
||
<button class="act primary" onclick="closeModal();window.__ask(true)">${esc(okLabel || '确定')}</button>`);
|
||
// 模态打不开时必须**立刻 resolve**,否则 await 永远挂着 —— 表现就是"点了没反应"
|
||
if (!opened) { resolve(false); return; }
|
||
window.__ask = resolve;
|
||
});
|
||
}
|
||
|
||
/** 替代 prompt:返回 Promise<string|null>。 */
|
||
function askText(title, label, options) {
|
||
const opts = options || {};
|
||
if (opts.choices) {
|
||
return new Promise((resolve) => {
|
||
const sel = opts.choices.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
|
||
const opened = openModal(title,
|
||
`<label style="font-size:12.5px;color:#5a6b7f">${esc(label)}</label>
|
||
<select id="ask-value" style="width:100%;margin-top:6px;padding:7px;border:1px solid #c9d2dd;border-radius:4px;font:inherit">${sel}</select>`,
|
||
`<button class="act" onclick="closeModal();window.__ask(null)">取消</button>
|
||
<button class="act primary" onclick="closeModal();window.__ask(document.getElementById('ask-value').value)">提交</button>`);
|
||
if (!opened) { resolve(null); return; }
|
||
window.__ask = resolve;
|
||
});
|
||
}
|
||
return new Promise((resolve) => {
|
||
const opened = openModal(title,
|
||
`<label style="font-size:12.5px;color:#5a6b7f">${esc(label)}</label>
|
||
<textarea id="ask-value" maxlength="${opts.maxlength || 500}">${esc(opts.value || '')}</textarea>
|
||
<div class="muted" style="margin-top:4px">${esc(opts.hint || '')}</div>`,
|
||
`<button class="act" onclick="closeModal();window.__ask(null)">取消</button>
|
||
<button class="act primary" onclick="closeModal();window.__ask(document.getElementById('ask-value').value)">提交</button>`);
|
||
if (!opened) { resolve(null); return; }
|
||
window.__ask = resolve;
|
||
});
|
||
}
|
||
|
||
/* ---------------- 统一响应处理(17- 文档 §六) ----------------
|
||
失败提示解析顺序:error.message -> HTTP 状态码映射 -> 通用失败提示。
|
||
注意本平台**业务失败也返回 HTTP 200**,错误在 body.code 里。 */
|
||
|
||
const HTTP_TEXT = {
|
||
401: '登录已失效,请重新登录',
|
||
403: '没有当前操作权限',
|
||
404: '记录不存在或无权查看',
|
||
409: '当前状态不允许该操作',
|
||
413: '文件超过大小限制',
|
||
422: '提交内容不符合要求',
|
||
500: '系统异常,请稍后重试',
|
||
503: '服务暂时不可用',
|
||
504: '服务暂时不可用',
|
||
};
|
||
|
||
/** 解析出「这次调用到底成没成」,并给出可直接展示的话。 */
|
||
function judge(r) {
|
||
const body = (r && r.body) || {};
|
||
const err = body.error || {};
|
||
const code = body.code;
|
||
const biz = (code === undefined || code === null) ? null : Number(code);
|
||
const httpBad = !r || r.status >= 400 || r.status === 0;
|
||
const bizBad = (biz !== null && biz !== 0) || !!err.code;
|
||
const failed = httpBad || bizBad;
|
||
|
||
let reason = '';
|
||
if (err.message) reason = err.message;
|
||
else if (bizBad && body.message) reason = body.message;
|
||
else if (bizBad && biz !== null) reason = HTTP_TEXT[biz] || (body.message || '');
|
||
if (!reason && httpBad) reason = HTTP_TEXT[(r || {}).status] || '请求失败';
|
||
if (!failed) return { failed: false, reason: '' };
|
||
|
||
const details = (body.error && body.error.field_errors) || body.field_errors || [];
|
||
if (details.length) {
|
||
reason += ':' + details.map((d) => `${d.field} ${d.message}`).join(';');
|
||
}
|
||
return { failed: true, reason: reason || '请求失败' };
|
||
}
|
||
|
||
/** 按 17- 文档 §六 给出「操作名 + 成功/失败」的提示文案。 */
|
||
function report(action, r, successText) {
|
||
const verdict = judge(r);
|
||
if (verdict.failed) {
|
||
const text = verdict.reason || '请求失败';
|
||
if ((r || {}).status === 409) {
|
||
bad(`${action}未完成:${text}\n可能已被处理,请刷新后核对状态`);
|
||
} else {
|
||
bad(`${action}失败:${text}`);
|
||
}
|
||
return false;
|
||
}
|
||
ok(successText || `${action}成功`);
|
||
return true;
|
||
}
|
||
|
||
// 统一调用平台接口:令牌在服务端,前端只传方法与路径
|
||
async function api(method, path, body, query) {
|
||
return jpost('/api/call', { method, path, body, query });
|
||
}
|
||
const GET = (path, query) => api('GET', path, null, query);
|
||
|
||
async function boot() {
|
||
const me = await (await fetch('/api/me', { headers: HEAD() })).json();
|
||
$('quick').innerHTML = (me.accounts || []).map((a) =>
|
||
`<button onclick="fill('${esc(a.username)}','${esc(a.password)}')">${esc(a.label)} · ${esc(a.username)}</button>`
|
||
).join('');
|
||
if (me.logged_in) { ME = me; showApp(); }
|
||
}
|
||
|
||
function fill(u, p) { $('u').value = u; $('p').value = p; $('login-err').classList.add('hidden'); }
|
||
|
||
async function doLogin() {
|
||
const err = $('login-err');
|
||
err.classList.add('hidden');
|
||
const r = await jpost('/api/login', { username: $('u').value.trim(), password: $('p').value });
|
||
if (!r.ok) {
|
||
const msg = (r.body && (r.body.error?.message || r.body.message)) || pretty(r.body || r);
|
||
err.textContent = '登录失败(HTTP ' + (r.status || '?') + '):' + msg;
|
||
err.classList.remove('hidden');
|
||
return;
|
||
}
|
||
ME = r.me;
|
||
showApp();
|
||
}
|
||
|
||
async function doLogout() { await jpost('/api/logout'); ME = null; location.reload(); }
|
||
|
||
let VIEW = null;
|
||
function showApp() {
|
||
$('login').style.display = 'none';
|
||
$('app').style.display = 'flex';
|
||
$('who').textContent = ME.username + '(' + ME.user_id + ')';
|
||
$('role').textContent = ME.roles.join(' / ') || '无角色';
|
||
$('env').textContent = (ME.environment?.mode || '') + ' · ' + (ME.environment?.mysql || ME.environment?.target || '');
|
||
if ($('build')) $('build').textContent = 'build ' + PAGE_BUILD;
|
||
VIEW = ME.view;
|
||
const tabs = [{ id: VIEW, label: ME.view_label }];
|
||
// 多角色时允许手动切到其他已具备的界面,便于一次演示
|
||
const others = new Set(ME.roles.map((r) => ({ super_admin:'admin', admin:'admin',
|
||
risk_operator:'staff', operator:'offsite', advisor:'advisor', customer:'customer' }[r])));
|
||
others.delete(VIEW);
|
||
for (const v of others) if (v) tabs.push({ id: v, label: { customer:'客服', staff:'风控工作台',
|
||
offsite:'运营工作台', admin:'权限管理', advisor:'投顾工作台' }[v] });
|
||
$('nav').innerHTML = tabs.map((t, i) =>
|
||
`<button class="${i === 0 ? 'active' : ''}" onclick="switchView('${t.id}', this)">${esc(t.label)}</button>`
|
||
).join('');
|
||
render(VIEW);
|
||
}
|
||
|
||
function switchView(v, el) {
|
||
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
render(v);
|
||
}
|
||
|
||
function render(view) {
|
||
const box = $('main');
|
||
if (view === 'customer') return renderCustomer(box);
|
||
if (view === 'staff') return renderStaff(box);
|
||
if (view === 'offsite') return renderOffsite(box);
|
||
if (view === 'admin') return renderAdmin(box);
|
||
if (view === 'advisor') return renderAdvisor(box);
|
||
box.innerHTML = `<div class="panel"><h2>未分配界面</h2>
|
||
<p class="hint">当前角色 <code>${esc(ME.roles.join(', ') || '无')}</code> 没有对应的工作台。</p>
|
||
<div>已授予的权限:${(ME.permissions || []).map((x) => `<span class="pill">${esc(x)}</span>`).join('') || '<span class="muted">无</span>'}</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------------- 客户 · 客服 ---------------- */
|
||
function renderCustomer(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>智能客服</h2>
|
||
<p class="hint">走真实链路:受理 <code>POST /api/v1/agent-runs</code> → 本进程驱动 Worker → 读取运行结果。
|
||
遇到答不了的问题,Agent 会引导你拨打客服热线。</p>
|
||
<div class="chatlog" id="log"></div>
|
||
<div class="composer">
|
||
<input id="msg" placeholder="例如:我要赎回基金需要多久到账?" onkeydown="if(event.key==='Enter')sendChat()">
|
||
<button class="act primary" onclick="sendChat()">发送</button>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="act" onclick="myProfile()">我的画像</button>
|
||
<button class="act" onclick="myCandidates()">我的画像候选</button>
|
||
<button class="act warn" onclick="askHandover()">转人工</button>
|
||
<span class="muted" id="chat-meta"></span>
|
||
</div>
|
||
</div>
|
||
<div class="panel" id="extra"></div>`;
|
||
addMsg('bot', '你好,我是智能客服助手。可以问我基金、理财、账户规则相关的问题。');
|
||
}
|
||
|
||
function addMsg(who, text, meta) {
|
||
const log = $('log');
|
||
const row = document.createElement('div');
|
||
row.className = 'msg ' + (who === 'me' ? 'me' : '');
|
||
const b = document.createElement('div');
|
||
b.className = 'bubble';
|
||
b.textContent = text;
|
||
row.appendChild(b);
|
||
if (meta) { const m = document.createElement('div'); m.className = 'muted'; m.style.marginLeft = '8px'; m.innerHTML = meta; row.appendChild(m); }
|
||
log.appendChild(row);
|
||
log.scrollTop = log.scrollHeight;
|
||
return row;
|
||
}
|
||
|
||
async function ensureSession() {
|
||
if (CONV) return CONV;
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/conversations',
|
||
body:{ agent_type:'customer_service' } });
|
||
const d = (r.body || {}).data || {};
|
||
CONV = d.session_id || d.id || null;
|
||
if (!CONV) { bad('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
|
||
return CONV;
|
||
}
|
||
|
||
async function sendChat() {
|
||
const box = $('msg');
|
||
const text = box.value.trim();
|
||
if (!text) return;
|
||
addMsg('me', text);
|
||
box.value = '';
|
||
const pending = addMsg('bot', '正在查询资料…');
|
||
$('chat-meta').textContent = '处理中…';
|
||
const sid = await ensureSession();
|
||
if (!sid) { pending.remove(); $('chat-meta').textContent = ''; return; }
|
||
const r = await jpost('/api/chat', { message: text, session_id: sid });
|
||
pending.remove();
|
||
$('chat-meta').textContent = '';
|
||
if (!r.ok) { addMsg('bot', '请求失败:' + pretty(r.body)); return; }
|
||
const tags = `<span class="tag">意图 ${esc(r.intent || '?')}</span>`
|
||
+ (r.transfer ? `<span class="tag warn">已转人工</span>` : '');
|
||
addMsg('bot', r.answer || '(空回答)', tags);
|
||
if (r.transfer) addMsg('bot', '转接原因:' + (r.transfer_reason || '未说明') + '\n如需人工服务,请拨打客服热线。');
|
||
}
|
||
|
||
async function myProfile() {
|
||
const r = await GET('/api/v1/users/me/memory-profile');
|
||
showExtra('我的画像 / 记忆', r);
|
||
}
|
||
|
||
async function myCandidates() {
|
||
const r = await GET('/api/v1/users/me/memory-candidates');
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
let html = `<h2>我的画像候选</h2>
|
||
<p class="hint">客服在对话中识别到的长期偏好会先成为「候选」,必须由你确认(或拒绝)后才会生效。</p>`;
|
||
if (!Array.isArray(items) || !items.length) {
|
||
html += `<div class="muted">暂无候选(HTTP ${r.status})。</div>`;
|
||
} else {
|
||
html += `<table><thead><tr><th>候选 ID</th><th>内容</th><th>状态</th><th>操作</th></tr></thead><tbody>`;
|
||
for (const c of items) {
|
||
html += `<tr><td><code>${esc(c.candidate_id ?? c.id)}</code></td>
|
||
<td>${esc(pretty(c.value ?? c.content ?? c))}</td>
|
||
<td>${esc(c.status)}</td>
|
||
<td>
|
||
<button class="act primary" onclick="decide('${esc(c.candidate_id ?? c.id)}','confirmed')">确认</button>
|
||
<button class="act" onclick="decide('${esc(c.candidate_id ?? c.id)}','rejected')">拒绝</button>
|
||
</td></tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
$('extra').innerHTML = html;
|
||
}
|
||
|
||
async function decide(id, decision) {
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path: `/api/v1/users/me/memory-candidates/${id}/decisions`, body: { decision } });
|
||
bad(`HTTP ${r.status}\n` + pretty(r.body));
|
||
myCandidates();
|
||
}
|
||
|
||
async function askHandover() {
|
||
const sid = await ensureSession();
|
||
if (!sid) return;
|
||
// 该接口只接受 `reason_code`(固定 user_requested)+ `reason_detail`(可选、≤500 字);
|
||
// 传 `reason` 会被 `additionalProperties: false` 判为额外字段,直接 422。
|
||
const detail = prompt('转人工说明(可留空,最多 500 字):', '需要人工协助');
|
||
if (detail === null) return;
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path: `/api/v1/conversations/${sid}/handover-requests`,
|
||
body: { reason_code: 'user_requested', reason_detail: detail.slice(0, 500) } });
|
||
showExtra('转人工结果', r);
|
||
}
|
||
|
||
function showExtra(title, r) {
|
||
$('extra').innerHTML = `<h2>${esc(title)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 当前角色权限不足(平台按设计 fail closed)' : ''}</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 员工 · 风控工作台 ----------------
|
||
按 docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md 实现:
|
||
概览五指标、预警队列每页 5 条 + 风险等级优先 + 筛选 + 游标分页 + 详情弹窗、
|
||
五个处置动作(确认接收二次确认、误报必填理由)、八类证据、通知、日报流式。
|
||
交互上不用原生 alert/prompt —— 改用 Toast 与模态框。 */
|
||
|
||
const EVIDENCE_SOURCES = [
|
||
['customers', '客户'], ['products', '产品'], ['transactions', '交易'],
|
||
['capital_flows', '资金'], ['holdings', '持仓'], ['login_records', '登录'],
|
||
['alerts', '预警'], ['notifications', '通知'],
|
||
];
|
||
// 注意是 `holdings` 而不是 positions —— 传错会被 422 拒绝。
|
||
const LEVEL_RANK = { '高风险': 0, '高': 0, '中风险': 1, '中': 1, '低风险': 2, '低': 2 };
|
||
const RISK_PAGE_SIZE = 5;
|
||
let RISK_CURSOR = null;
|
||
let RISK_CURSOR_STACK = [];
|
||
let RISK_HAS_MORE = false;
|
||
let RISK_ITEMS = [];
|
||
let RISK_DETAIL = null;
|
||
|
||
function renderStaff(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>风险概览</h2>
|
||
<p class="hint">数据范围 <code>all</code>(风控专员可看全部客户)。五个指标取自
|
||
<code>GET /api/v1/risk/overview</code>。</p>
|
||
<div class="grid" id="risk-overview"><div class="stat"><div class="n">…</div><div class="l">加载中</div></div></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="act primary" onclick="loadRisk()">刷新概览</button>
|
||
<button class="act" onclick="loadAlerts(null)">刷新队列</button>
|
||
<button class="act warn" onclick="scanRisk()">手动扫描</button>
|
||
<button class="act" onclick="openDailyReport()">生成日报</button>
|
||
<button class="act" onclick="openNotifications()">通知记录</button>
|
||
<button class="act" onclick="openEvidence()">八类证据</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>预警队列<span class="muted" id="risk-count"></span></h2>
|
||
<p class="hint">固定每页 ${RISK_PAGE_SIZE} 条、按风险等级优先。**排序在页内进行** ——
|
||
接口本身不支持排序参数,跨页整体排序需要服务端支持。<br>
|
||
⚠️ 风险等级筛选值必须用 <b>高 / 中 / 低</b>:预警对象的 <code>risk_level</code> 是「高」,
|
||
而概览的 <code>levels</code> 键是「高风险」—— 平台两处口径不一致,用「高风险」筛不出任何结果。</p>
|
||
<div class="filters">
|
||
<label>关键词<input id="f-keyword" placeholder="预警号/摘要"></label>
|
||
<label>客户号<input id="f-customer_no" placeholder="T-CUST"></label>
|
||
<label>风险等级<select id="f-risk_level">
|
||
<option value="">全部</option>
|
||
<option value="高">高</option>
|
||
<option value="中">中</option>
|
||
<option value="低">低</option>
|
||
</select></label>
|
||
<label>规则码<input id="f-rule_code" placeholder="RW-015"></label>
|
||
<label>产品代码<input id="f-product_code"></label>
|
||
<label>产品名<input id="f-product_name"></label>
|
||
<label>开始时间<input id="f-start_time" placeholder="2026-09-01"></label>
|
||
<label>结束时间<input id="f-end_time" placeholder="2026-09-30"></label>
|
||
<button class="act primary" onclick="loadAlerts(null)">查询</button>
|
||
<button class="act" onclick="resetRiskFilters()">重置</button>
|
||
</div>
|
||
<div id="alerts"><div class="muted">加载中…</div></div>
|
||
<div class="pager">
|
||
<button class="act" id="pg-prev" onclick="riskPrevPage()">上一页</button>
|
||
<button class="act" id="pg-next" onclick="riskNextPage()">下一页</button>
|
||
<span id="pg-info"></span>
|
||
</div>
|
||
</div>
|
||
<div class="panel" id="staff-extra"></div>`;
|
||
loadRisk();
|
||
loadAlerts(null);
|
||
}
|
||
|
||
function resetRiskFilters() {
|
||
['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
|
||
'product_name', 'start_time', 'end_time'].forEach((k) => { $('f-' + k).value = ''; });
|
||
loadAlerts(null);
|
||
}
|
||
|
||
function riskQuery() {
|
||
const q = {};
|
||
['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
|
||
'product_name', 'start_time', 'end_time'].forEach((k) => {
|
||
const v = ($('f-' + k) || {}).value;
|
||
if (v && v.trim()) q[k] = v.trim();
|
||
});
|
||
q.limit = RISK_PAGE_SIZE; // 该接口 limit 上限就是 5
|
||
return q;
|
||
}
|
||
|
||
async function loadRisk() {
|
||
const r = await GET('/api/v1/risk/overview');
|
||
const d = (r.body || {}).data || {};
|
||
const verdict = judge(r);
|
||
if (verdict.failed) {
|
||
$('risk-overview').innerHTML = `<div class="stat"><div class="n">—</div><div class="l">${esc(verdict.reason)}</div></div>`;
|
||
return;
|
||
}
|
||
const levels = d.levels || {};
|
||
const hi = (d.high_priority || []).length;
|
||
const cards = [
|
||
['预警总量', d.total],
|
||
['待处理', d.pending],
|
||
['已超时', d.overdue],
|
||
['高风险', levels['高风险'] ?? levels['高'] ?? 0],
|
||
['重点预警', hi],
|
||
];
|
||
$('risk-overview').innerHTML = cards.map(([label, value]) =>
|
||
`<div class="stat"><div class="n">${esc(value ?? '—')}</div><div class="l">${esc(label)}</div></div>`).join('');
|
||
}
|
||
|
||
async function loadAlerts(cursor) {
|
||
if (cursor === null || cursor === undefined) { RISK_CURSOR_STACK = []; }
|
||
const q = riskQuery();
|
||
if (cursor) q.cursor = cursor;
|
||
const r = await GET('/api/v1/risk/alerts', q);
|
||
const verdict = judge(r);
|
||
const box = $('alerts');
|
||
if (verdict.failed) {
|
||
box.innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`;
|
||
$('pg-info').textContent = '';
|
||
return;
|
||
}
|
||
const data = (r.body || {}).data;
|
||
const items = Array.isArray(data) ? data : ((data || {}).items || []);
|
||
const meta = (r.body || {}).meta || {};
|
||
RISK_HAS_MORE = !!meta.has_more;
|
||
RISK_CURSOR = meta.next_cursor || null;
|
||
// 页内按风险等级优先,同级按 priority_score 降序
|
||
RISK_ITEMS = items.slice().sort((a, b) => {
|
||
const ra = LEVEL_RANK[a.risk_level] ?? 9;
|
||
const rb = LEVEL_RANK[b.risk_level] ?? 9;
|
||
if (ra !== rb) return ra - rb;
|
||
return (b.priority_score || 0) - (a.priority_score || 0);
|
||
});
|
||
$('risk-count').textContent = `(本页 ${RISK_ITEMS.length} 条)`;
|
||
|
||
if (!RISK_ITEMS.length) {
|
||
box.innerHTML = '<div class="muted">没有符合条件的预警。可调整筛选条件或点「手动扫描」。</div>';
|
||
} else {
|
||
box.innerHTML = `<table class="fixed"><thead><tr>
|
||
<th style="width:112px">预警号</th><th style="width:110px">客户</th>
|
||
<th style="width:74px">等级</th><th style="width:150px">规则</th>
|
||
<th style="width:80px">处置状态</th><th style="width:70px">回执</th>
|
||
<th>摘要</th><th style="width:150px">操作</th></tr></thead><tbody>`
|
||
+ RISK_ITEMS.map((a) => {
|
||
const no = esc(a.alert_no || '');
|
||
const lv = esc(a.risk_level || '');
|
||
const rules = (a.rule_codes || []).join(', ');
|
||
const ack = a.ack_status === '已确认' ? '已确认' : '未确认';
|
||
const escMark = a.is_escalated ? '<span class="lv lv-高">已升级</span>' : '';
|
||
return `<tr>
|
||
<td><code>${no}</code></td>
|
||
<td>${esc(a.customer_name || a.customer_no || '')}</td>
|
||
<td><span class="lv lv-${lv}">${lv}</span></td>
|
||
<td title="${esc(rules)}">${esc(rules)}</td>
|
||
<td>${esc(a.status || '')}</td>
|
||
<td>${esc(ack)} ${escMark}</td>
|
||
<td title="${esc(a.evidence_summary || '')}">${esc(a.evidence_summary || '')}</td>
|
||
<td>
|
||
<button class="act" onclick="openAlert('${no}')">详情</button>
|
||
<button class="act" onclick="ack('${no}')">确认</button>
|
||
</td></tr>`;
|
||
}).join('')
|
||
+ '</tbody></table>';
|
||
}
|
||
$('pg-prev').disabled = RISK_CURSOR_STACK.length === 0;
|
||
$('pg-next').disabled = !RISK_HAS_MORE;
|
||
$('pg-info').textContent =
|
||
`第 ${RISK_CURSOR_STACK.length + 1} 页 · 每页 ${RISK_PAGE_SIZE} 条` + (RISK_HAS_MORE ? ' · 还有下一页' : ' · 已到末页');
|
||
}
|
||
|
||
function riskNextPage() {
|
||
if (!RISK_HAS_MORE) return;
|
||
RISK_CURSOR_STACK.push(RISK_CURSOR);
|
||
loadAlerts(RISK_CURSOR);
|
||
}
|
||
function riskPrevPage() {
|
||
if (!RISK_CURSOR_STACK.length) return;
|
||
const prev = RISK_CURSOR_STACK.pop();
|
||
loadAlerts(RISK_CURSOR_STACK.length ? prev : null);
|
||
}
|
||
|
||
function levelPill(lv) {
|
||
const t = esc(lv || '');
|
||
return `<span class="lv lv-${t}">${t}</span>`;
|
||
}
|
||
|
||
async function openAlert(no) {
|
||
const r = await GET(`/api/v1/risk/alerts/${no}`);
|
||
const verdict = judge(r);
|
||
if (verdict.failed) { bad(`打开预警详情失败:${verdict.reason}`); return; }
|
||
const d = (r.body || {}).data || {};
|
||
const a = d.alert || {};
|
||
const c = d.customer || {};
|
||
RISK_DETAIL = a;
|
||
const ack = a.ack_status === '已确认';
|
||
const snap = a.evidence_snapshot || {};
|
||
openModal(`预警详情 ${a.alert_no || no}`, `
|
||
<dl class="kv">
|
||
<dt>预警编号</dt><dd><code>${esc(a.alert_no)}</code></dd>
|
||
<dt>预警类型</dt><dd>${esc(a.alert_type)}</dd>
|
||
<dt>风险等级</dt><dd>${levelPill(a.risk_level)} · 优先级分 ${esc(a.priority_score)}</dd>
|
||
<dt>处置状态</dt><dd>${esc(a.status)} ${a.is_escalated ? '(已升级标记)' : ''}</dd>
|
||
<dt>回执状态</dt><dd>${esc(a.ack_status)}${a.ack_at ? ' @ ' + esc(a.ack_at) : ''}</dd>
|
||
<dt>证据归档</dt><dd>${a.evidence_archived ? '已归档' : '未归档'}</dd>
|
||
<dt>规则</dt><dd>${(a.rule_codes || []).map((x) => `<span class="pill">${esc(x)}</span>`).join('')}</dd>
|
||
<dt>到期时间</dt><dd>${esc(a.due_at)}</dd>
|
||
<dt>结案原因</dt><dd>${esc(a.close_reason || '—')}</dd>
|
||
<dt>证据摘要</dt><dd>${esc(a.evidence_summary)}</dd>
|
||
<dt>证据快照</dt><dd><pre style="margin:0">${esc(pretty(snap))}</pre></dd>
|
||
<dt>客户</dt><dd>${esc(c.name)}(${esc(c.customer_no)})· 投资者类型 ${esc(c.investor_type)} ·
|
||
行为分 ${esc(c.behavior_score)} · 总资产 ${esc(c.total_asset)}</dd>
|
||
<dt>风险标签</dt><dd>${esc(c.risk_tags || '—')}</dd>
|
||
</dl>`,
|
||
`<button class="act" onclick="ack('${esc(a.alert_no)}')" ${ack ? 'disabled' : ''}>确认接收</button>
|
||
<button class="act" onclick="investigate('${esc(a.alert_no)}')">进入调查</button>
|
||
<button class="act warn" onclick="exclude('${esc(a.alert_no)}')">关闭误报</button>
|
||
<button class="act" onclick="escalate('${esc(a.alert_no)}')">升级</button>
|
||
<button class="act primary" onclick="resolveAlert('${esc(a.alert_no)}')">完成结案</button>
|
||
<button class="act" onclick="closeModal()">关闭</button>`);
|
||
}
|
||
|
||
/* 五个处置动作。写操作都走二次确认,误报/升级/结案必填文字。 */
|
||
async function ack(no) {
|
||
if (!await askConfirm('确认接收', `确认接收预警 ${no}?\n确认后才会进入可处置状态。`, '确认接收')) return;
|
||
const r = await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/risk/alerts/${no}/acknowledgements`, body:{} });
|
||
if (report('确认接收', r, '预警已确认接收')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
|
||
}
|
||
|
||
async function investigate(no) {
|
||
if (!await askConfirm('进入调查', `将预警 ${no} 标记为调查中?`, '进入调查')) return;
|
||
const r = await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/risk/alerts/${no}/investigations`, body:{} });
|
||
if (report('进入调查', r, '已进入调查')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
|
||
}
|
||
|
||
async function exclude(no) {
|
||
const reason = await askText('关闭误报', '误报理由(必填,1-500 字)',
|
||
{ value: '', maxlength: 500, hint: '本题为必填项,平台会校验。' });
|
||
if (reason === null) return;
|
||
if (!reason.trim()) { bad('误报理由不能为空'); return; }
|
||
const r = await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/risk/alerts/${no}/exclusions`, body:{ reason: reason.trim() } });
|
||
if (report('关闭误报', r, '已关闭误报')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
|
||
}
|
||
|
||
async function escalate(no) {
|
||
const reason = await askText('升级处理', '升级原因(必填,1-500 字)',
|
||
{ value: '客户风险等级需人工复核', maxlength: 500 });
|
||
if (reason === null) return;
|
||
if (!reason.trim()) { bad('升级原因不能为空'); return; }
|
||
const r = await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/risk/alerts/${no}/escalations`, body:{ reason: reason.trim() } });
|
||
if (report('升级处理', r, '预警已升级')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
|
||
}
|
||
|
||
async function resolveAlert(no) {
|
||
const resolution = await askText('完成结案', '处理结论(必填,1-500 字)',
|
||
{ value: '已联系客户核实,风险已排除', maxlength: 500 });
|
||
if (resolution === null) return;
|
||
if (!resolution.trim()) { bad('处理结论不能为空'); return; }
|
||
const r = await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/risk/alerts/${no}/resolutions`, body:{ resolution: resolution.trim() } });
|
||
if (report('完成结案', r, '预警已完成结案')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
|
||
}
|
||
|
||
async function scanRisk() {
|
||
if (!await askConfirm('手动扫描', '触发一次风控规则扫描?会产生新的预警与审计记录。', '开始扫描')) return;
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/alerts/scan', body:{} });
|
||
if (report('手动扫描', r, '预警扫描完成')) { loadRisk(); loadAlerts(null); }
|
||
}
|
||
|
||
/* 日报:SSE 流式生成 -> 可编辑 -> 多邮箱发送 */
|
||
async function openDailyReport() {
|
||
openModal('生成风控日报', `
|
||
<div class="row"><button class="act primary" onclick="streamDailyReport()">开始流式生成</button>
|
||
<span class="muted">使用 <code>POST /api/v1/risk/daily-report/stream</code>(SSE)</span></div>
|
||
<div class="stream" id="dr-stream">(尚未开始)</div>
|
||
<label style="display:block;margin-top:12px;font-size:12.5px;color:#5a6b7f">日报内容(可编辑后再发送)</label>
|
||
<textarea id="dr-content" style="min-height:200px"></textarea>
|
||
<div class="row" style="margin-top:10px">
|
||
<input class="q" id="dr-subject" value="风控预警日报" placeholder="邮件主题" style="width:220px">
|
||
<input class="q" id="dr-to" placeholder="收件人,逗号分隔" style="width:280px">
|
||
<button class="act primary" onclick="sendDailyReport()">发送日报</button>
|
||
</div>
|
||
<div class="muted">发送接口要 <code>recipients</code> / <code>subject</code> / <code>content</code> 三个字段。</div>`);
|
||
}
|
||
|
||
async function streamDailyReport() {
|
||
const box = $('dr-stream');
|
||
box.textContent = '';
|
||
try {
|
||
const resp = await fetch('/api/call', {
|
||
method: 'POST', headers: HEAD(),
|
||
body: JSON.stringify({ method:'POST', path:'/api/v1/risk/daily-report/stream', body:{} }),
|
||
});
|
||
const payload = await resp.json();
|
||
const raw = ((payload.body || {})._raw) || '';
|
||
if (!raw) {
|
||
box.textContent = pretty(payload.body);
|
||
const verdict = judge(payload);
|
||
if (verdict.failed) bad('日报生成失败:' + verdict.reason);
|
||
return;
|
||
}
|
||
// 逐条解析 SSE:event: xxx / data: {...}
|
||
let content = '';
|
||
raw.split('\n').forEach((line) => {
|
||
if (line.startsWith('data:')) {
|
||
try {
|
||
const obj = JSON.parse(line.slice(5).trim());
|
||
if (obj.type === 'replace' && obj.content) content = obj.content;
|
||
else if (obj.content) content += obj.content;
|
||
if (obj.message) box.textContent += `[${obj.stage || obj.type}] ${obj.message}\n`;
|
||
} catch { /* 忽略非 JSON 行 */ }
|
||
} else if (line.startsWith('event:')) {
|
||
box.textContent += `── ${line.slice(6).trim()} ──\n`;
|
||
}
|
||
});
|
||
if (content) { $('dr-content').value = content; ok('日报生成完成'); }
|
||
else warn('日报流已结束,但没有解析到内容');
|
||
} catch (err) {
|
||
bad('日报流式请求失败:' + err.message);
|
||
}
|
||
}
|
||
|
||
async function sendDailyReport() {
|
||
const content = $('dr-content').value.trim();
|
||
const subject = $('dr-subject').value.trim();
|
||
const to = $('dr-to').value.split(',').map((x) => x.trim()).filter(Boolean);
|
||
if (!content) { bad('日报内容为空,先生成或填写'); return; }
|
||
if (!to.length) { bad('请至少填一个收件人'); return; }
|
||
if (!await askConfirm('发送日报', `发送给 ${to.join(', ')}?`, '发送')) return;
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report/mail',
|
||
body:{ recipients: to, subject, content } });
|
||
report('邮件发送', r, '日报发送成功');
|
||
}
|
||
|
||
/* 八类证据 */
|
||
async function openEvidence() {
|
||
openModal('八类证据', `
|
||
<div class="tabs" id="ev-tabs">${EVIDENCE_SOURCES.map(([k, label], i) =>
|
||
`<button class="${i === 0 ? 'on' : ''}" onclick="loadEvidence('${k}', this)">${esc(label)}</button>`).join('')}</div>
|
||
<div class="filters">
|
||
<label>关键词<input id="ev-keyword"></label>
|
||
<label>行为分等级<input id="ev-behavior_level" placeholder="如 关注"></label>
|
||
<label>发送状态<input id="ev-send_status" placeholder="如 成功"></label>
|
||
<label>开始时间<input id="ev-start_time" placeholder="2026-09-01"></label>
|
||
<label>结束时间<input id="ev-end_time" placeholder="2026-09-30"></label>
|
||
<button class="act primary" onclick="reloadEvidence()">查询</button>
|
||
</div>
|
||
<div id="ev-body"><div class="muted">加载中…</div></div>`,
|
||
'<button class="act" onclick="closeModal()">关闭</button>');
|
||
loadEvidence('customers');
|
||
}
|
||
|
||
let EV_SOURCE = 'customers';
|
||
async function loadEvidence(source, btn) {
|
||
EV_SOURCE = source;
|
||
if (btn) {
|
||
document.querySelectorAll('#ev-tabs button').forEach((b) => b.classList.remove('on'));
|
||
btn.classList.add('on');
|
||
}
|
||
const q = {};
|
||
['keyword', 'behavior_level', 'send_status', 'start_time', 'end_time'].forEach((k) => {
|
||
const v = ($('ev-' + k) || {}).value;
|
||
if (v && v.trim()) q[k] = v.trim();
|
||
});
|
||
const r = await GET(`/api/v1/risk/evidence/${source}`, q);
|
||
const verdict = judge(r);
|
||
const box = $('ev-body');
|
||
if (verdict.failed) { box.innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`; return; }
|
||
const data = (r.body || {}).data;
|
||
const items = Array.isArray(data) ? data : ((data || {}).items || []);
|
||
if (!items.length) { box.innerHTML = '<div class="muted">该类证据暂无数据。</div>'; return; }
|
||
const cols = Object.keys(items[0]);
|
||
box.innerHTML = `<div class="muted" style="margin-bottom:6px">${items.length} 条 · 来源
|
||
<code>${esc(source)}</code></div>
|
||
<table class="fixed"><thead><tr>${cols.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>`
|
||
+ items.slice(0, 30).map((row) => '<tr>' + cols.map((c) => {
|
||
const v = row[c];
|
||
const text = (v === null || v === undefined) ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||
return `<td title="${esc(text)}">${esc(text)}</td>`;
|
||
}).join('') + '</tr>').join('')
|
||
+ '</tbody></table>';
|
||
}
|
||
|
||
function reloadEvidence() { loadEvidence(EV_SOURCE); }
|
||
|
||
/* 通知记录 */
|
||
async function openNotifications() {
|
||
openModal('通知记录', '<div id="nt-body"><div class="muted">加载中…</div></div>',
|
||
'<button class="act" onclick="closeModal()">关闭</button>');
|
||
const r = await GET('/api/v1/risk/notifications');
|
||
const verdict = judge(r);
|
||
if (verdict.failed) { $('nt-body').innerHTML = `<div class="muted">加载失败:${esc(verdict.reason)}</div>`; return; }
|
||
const data = (r.body || {}).data;
|
||
const items = Array.isArray(data) ? data : ((data || {}).items || []);
|
||
$('nt-body').innerHTML = items.length
|
||
? `<table class="fixed"><thead><tr><th>预警编号</th><th>类型</th><th>发送状态</th><th>时间</th></tr></thead><tbody>`
|
||
+ items.map((n) => `<tr><td><code>${esc(n.alert_no ?? '')}</code></td>
|
||
<td>${esc(n.notification_type ?? n.type ?? '')}</td>
|
||
<td>${esc(n.send_status ?? n.status ?? '')}</td>
|
||
<td>${esc(n.created_at ?? n.sent_at ?? '')}</td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: '<div class="muted">暂无通知记录。</div>';
|
||
}
|
||
|
||
/* ---------------- 员工 · 运营工作台(场外基金) ---------------- */
|
||
function renderOffsite(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>运营工作台 · 场外基金</h2>
|
||
<p class="hint">面向 <code>operator</code>。场外线的服务层用**角色门槛**
|
||
<code>{"operator","risk_operator","admin","super_admin"}</code> 判断,所以主体功能靠角色就通。
|
||
另外:场外的**写接口必须带 <code>operator_id</code>**,而且是**防伪校验** —— 平台会核对
|
||
它是否等于当前登录用户,所以门户一律自动带本次登录的 user_id,不让你手填。</p>
|
||
<div class="grid" id="mailbox"><div class="stat"><div class="n">…</div><div class="l">邮箱状态加载中</div></div></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="act primary" onclick="loadMailbox()">刷新邮箱状态</button>
|
||
<button class="act" onclick="loadMails()">拉取邮件列表</button>
|
||
<button class="act warn" onclick="recoverMailbox()">触发邮箱恢复</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>邮件与识别</h2>
|
||
<div id="mails"><div class="muted">未加载</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>单据处理</h2>
|
||
<p class="hint">填单据号(task_id)后可查看识别字段与规则判定,并执行确认 / 重试 / 通知。</p>
|
||
<div class="row">
|
||
<input class="q" id="task" placeholder="单据号 task_id">
|
||
<button class="act" onclick="docFields()">识别字段</button>
|
||
<button class="act" onclick="docRules()">规则结果</button>
|
||
<button class="act primary" onclick="docConfirm()">确认单据</button>
|
||
<button class="act warn" onclick="docRetry()">重试识别</button>
|
||
<button class="act" onclick="docNotify()">创建通知</button>
|
||
</div>
|
||
<div id="offsite-extra"></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>结算统计</h2>
|
||
<div class="row"><button class="act warn" onclick="settle()">重算结算统计</button></div>
|
||
</div>`;
|
||
loadMailbox();
|
||
}
|
||
|
||
async function loadMailbox() {
|
||
const r = await GET('/api/v1/offsite-fund/mailbox-status');
|
||
const d = r.body?.data || {};
|
||
const entries = Object.entries(d).filter(([, v]) => typeof v !== 'object');
|
||
$('mailbox').innerHTML = entries.length
|
||
? entries.map(([k, v]) => `<div class="stat"><div class="n">${esc(v)}</div><div class="l">${esc(k)}</div></div>`).join('')
|
||
: `<div class="stat"><div class="n">${r.status}</div><div class="l">邮箱状态</div></div>`;
|
||
if (!entries.length) showOffsite('邮箱状态原始返回', r);
|
||
}
|
||
|
||
async function loadMails() {
|
||
// 该接口的分页参数是 `page` / `page_size`(不是 limit)。
|
||
const r = await GET('/api/v1/offsite-fund/mails', { page: 1, page_size: 20 });
|
||
const raw = r.body?.data;
|
||
const items = Array.isArray(raw) ? raw : (raw?.items || []);
|
||
$('mails').innerHTML = Array.isArray(items) && items.length
|
||
? `<table><thead><tr><th>邮件 ID</th><th>主题</th><th>状态</th><th>操作</th></tr></thead><tbody>`
|
||
+ items.map((m) => `<tr><td><code>${esc(m.mail_id ?? m.id)}</code></td>
|
||
<td>${esc(m.subject ?? '')}</td><td>${esc(m.status ?? '')}</td>
|
||
<td><button class="act" onclick="mailFields('${esc(m.mail_id ?? m.id)}')">识别字段</button>
|
||
<button class="act warn" onclick="mailDelete('${esc(m.mail_id ?? m.id)}')">删除</button></td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: `<div class="muted">HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}</div><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
// 场外线的写接口**必须带 operator_id**,而且是防伪校验:平台会核对它是否等于当前
|
||
// 登录用户。所以这里一律取本次登录的 user_id,不硬编码、也不让用户随便填。
|
||
function myId() { return (ME && ME.user_id) || ''; }
|
||
|
||
async function mailFields(id) {
|
||
showOffsite('邮件识别字段 ' + id, await GET(`/api/v1/offsite-fund/mails/${id}/recognition-fields`));
|
||
}
|
||
async function mailDelete(id) {
|
||
if (!confirm('删除邮件 ' + id + '?这是写操作。')) return;
|
||
showOffsite('删除邮件 ' + id, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/mails/${id}/deletions`,
|
||
body:{ operator_id: myId() } }));
|
||
loadMails();
|
||
}
|
||
async function recoverMailbox() {
|
||
if (!confirm('触发邮箱恢复?这是写操作。')) return;
|
||
showOffsite('邮箱恢复', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries',
|
||
body:{ operator_id: myId() } }));
|
||
}
|
||
async function docFields() {
|
||
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
|
||
showOffsite('识别字段 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/nl2sql-fields`));
|
||
}
|
||
async function docRules() {
|
||
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
|
||
showOffsite('规则结果 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/rule-results`));
|
||
}
|
||
async function docConfirm() {
|
||
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
|
||
// decision 是**中文枚举**:确认无误 / 确认异常 / 未处理
|
||
const decision = prompt('确认结论(确认无误 / 确认异常 / 未处理):', '确认无误');
|
||
if (decision === null) return;
|
||
if (['确认无误', '确认异常', '未处理'].indexOf(decision) < 0) {
|
||
return bad('只能是:确认无误 / 确认异常 / 未处理');
|
||
}
|
||
if (!confirm(`对单据 ${t} 提交「${decision}」?这是写操作,会进审计。`)) return;
|
||
showOffsite('确认单据 ' + t, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`,
|
||
body:{ decision, operator_id: myId() } }));
|
||
}
|
||
async function docRetry() {
|
||
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
|
||
showOffsite('重试识别 ' + t, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`,
|
||
body:{ operator_id: myId() } }));
|
||
}
|
||
async function docNotify() {
|
||
const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
|
||
// notification_type 取值:risk / settlement / mail_return / normal_return / exception_return…
|
||
const type = prompt('通知类型(risk / settlement / mail_return / normal_return / exception_return):',
|
||
'normal_return');
|
||
if (type === null) return;
|
||
if (!confirm(`为单据 ${t} 创建「${type}」通知?`)) return;
|
||
showOffsite('创建通知 ' + t, await jpost('/api/call',
|
||
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`,
|
||
body:{ notification_type: type, operator_id: myId() } }));
|
||
}
|
||
async function settle() {
|
||
// 这个接口要的是 fund_code + application_date,没有 operator_id
|
||
const fund = prompt('基金代码 fund_code:', '');
|
||
if (fund === null) return;
|
||
const date = prompt('申请日期 application_date(YYYY-MM-DD):', '');
|
||
if (date === null) return;
|
||
if (!fund.trim() || !date.trim()) return bad('基金代码与申请日期都必填');
|
||
if (!confirm(`重算 ${fund} 在 ${date} 的结算统计?这是写操作。`)) return;
|
||
showOffsite('结算重算', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate',
|
||
body:{ fund_code: fund.trim(), application_date: date.trim() } }));
|
||
}
|
||
function showOffsite(title, r) {
|
||
$('offsite-extra').innerHTML = `<h2>${esc(title)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${r.status === 403 ? ' —— 权限不足(fail closed)' : ''}</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 管理员 · 权限界面 ---------------- */
|
||
function renderAdmin(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>角色与权限</h2>
|
||
<p class="hint">这是平台真实的 RBAC 数据(<code>GET /api/v1/admin/roles</code> 等四个只读接口)。
|
||
平台**只提供只读查询**:改权限要发布新的 <code>config_release</code>,不提供直接写接口 —— 这是设计,不是缺失。</p>
|
||
<div class="grid" id="roles"><div class="stat"><div class="n">…</div><div class="l">加载中</div></div></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<input class="q" id="userid" placeholder="按用户 ID 查角色,例如 9001">
|
||
<button class="act primary" onclick="userRoles()">查询该用户的角色</button>
|
||
</div>
|
||
<div id="user-roles"></div>
|
||
</div>
|
||
<div class="panel" id="role-detail"></div>
|
||
<div class="panel">
|
||
<h2>审计流水</h2>
|
||
<p class="hint">谁做了什么、有没有被拒,都记在这里(<code>GET /api/v1/admin/audit-records</code>)。</p>
|
||
<div class="row"><input class="q" id="audit-limit" value="20" style="width:80px">
|
||
<span class="muted">1-100</span>
|
||
<button class="act" onclick="loadAudit()">刷新</button></div>
|
||
<div id="audit"><div class="muted">加载中…</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>客服转人工工单</h2>
|
||
<p class="hint">只读队列;接口不返回客户标识与原始会话正文。</p>
|
||
<div class="row"><button class="act" onclick="loadTickets()">刷新工单</button></div>
|
||
<div id="tickets"><div class="muted">未加载</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>知识库</h2>
|
||
<div class="row"><button class="act" onclick="loadKnowledge()">列出知识文档</button></div>
|
||
<div id="knowledge"><div class="muted">未加载</div></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>推广材料审核</h2>
|
||
<p class="hint">审核需要 <code>promotion:review</code>,只给管理员 —— 投顾没有这个码(职责分离),
|
||
所以"生成"之后必须由管理员在这里通过,投顾才能投递。<br>
|
||
<b>版本号从哪来</b>:投顾执行「生成」后,响应里的 <code>material_version_id</code> 就是它
|
||
(状态 <code>pending_review</code>)。注意本平台<b>失败时也返回 HTTP 200</b>,
|
||
判断成败要看 <code>body.code</code>。</p>
|
||
<div class="row">
|
||
<input class="q" id="promo-task" placeholder="任务单号 PM-..." style="width:220px">
|
||
<button class="act" onclick="admPromoLookup()">查任务(自动填版本号)</button>
|
||
</div>
|
||
<div class="row">
|
||
<input class="q" id="adm-version" placeholder="material_version_id" style="width:150px">
|
||
<input class="q" id="adm-comment" placeholder="审核意见(可选)" style="width:240px">
|
||
<button class="act primary" onclick="admReview('approved')">通过</button>
|
||
<button class="act warn" onclick="admReview('revision_requested')">退回修改</button>
|
||
<button class="act warn" onclick="admReview('rejected')">拒绝</button>
|
||
</div>
|
||
<div id="promo-admin-extra"></div>
|
||
</div>`;
|
||
loadRoles();
|
||
loadAudit();
|
||
}
|
||
|
||
async function loadRoles() {
|
||
const r = await GET('/api/v1/admin/roles');
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
if (!Array.isArray(items) || !items.length) {
|
||
$('roles').innerHTML = `<div class="stat"><div class="n">${r.status}</div><div class="l">角色查询返回</div></div>`;
|
||
$('role-detail').innerHTML = `<h2>原始返回</h2><pre>${esc(pretty(r.body))}</pre>`;
|
||
return;
|
||
}
|
||
$('roles').innerHTML = items.map((x) => `<div class="stat" style="cursor:pointer"
|
||
onclick="roleDetail('${esc(x.role_code)}')">
|
||
<div class="n">${esc(x.permission_count ?? '?')}</div>
|
||
<div class="l">${esc(x.role_name || x.role_code)} · <code>${esc(x.role_code)}</code>
|
||
· ${esc(x.user_count ?? 0)} 人</div></div>`).join('');
|
||
$('role-detail').innerHTML = '<h2>点上面的角色卡片看它的权限</h2>';
|
||
}
|
||
|
||
async function roleDetail(code) {
|
||
const [detail, perms] = await Promise.all([
|
||
GET(`/api/v1/admin/roles/${code}`),
|
||
GET(`/api/v1/admin/roles/${code}/permissions`),
|
||
]);
|
||
const items = perms.body?.data?.items || perms.body?.data || [];
|
||
const list = Array.isArray(items) ? items : [];
|
||
$('role-detail').innerHTML = `
|
||
<h2>角色 <code>${esc(code)}</code> 的权限(${list.length} 项)</h2>
|
||
<p class="hint">HTTP ${perms.status}。下面是这个角色实际能通过的权限码 ——
|
||
客户点开风控接口会 403,就是因为这里没有对应的码。</p>
|
||
<div>${list.map((p) => `<span class="pill">${esc(p.permission_code ?? p.code ?? p)}</span>`).join('') || '<span class="muted">无</span>'}</div>
|
||
<details style="margin-top:12px"><summary class="muted">角色详情原始返回</summary>
|
||
<pre>${esc(pretty(detail.body))}</pre></details>`;
|
||
}
|
||
|
||
async function userRoles() {
|
||
const id = $('userid').value.trim() || '9001';
|
||
const r = await GET(`/api/v1/admin/users/${id}/roles`);
|
||
$('user-roles').innerHTML = `<p class="hint">HTTP ${r.status}</p><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function loadAudit() {
|
||
// 该接口 limit 的取值范围是 1-100;钳一下,免得填大值得到 422。
|
||
let limit = parseInt($('audit-limit').value.trim() || '20', 10);
|
||
if (!Number.isFinite(limit)) limit = 20;
|
||
limit = Math.min(100, Math.max(1, limit));
|
||
$('audit-limit').value = limit;
|
||
const r = await GET('/api/v1/admin/audit-records', { limit });
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
const box = $('audit');
|
||
if (!Array.isArray(items) || !items.length) {
|
||
box.innerHTML = `<div class="muted">暂无记录(HTTP ${r.status})。</div><pre>${esc(pretty(r.body))}</pre>`;
|
||
return;
|
||
}
|
||
box.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>操作者</th><th>结果</th></tr></thead><tbody>`
|
||
+ items.map((a) => `<tr><td>${esc(a.created_at)}</td><td><code>${esc(a.action_type)}</code></td>
|
||
<td>${esc(a.actor_id ?? a.actor_type)}</td><td>${esc(a.result ?? '')}</td></tr>`).join('')
|
||
+ '</tbody></table>';
|
||
}
|
||
|
||
async function loadTickets() {
|
||
const r = await GET('/api/v1/admin/customer-service/handover-tickets', { limit: 20 });
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
const box = $('tickets');
|
||
box.innerHTML = Array.isArray(items) && items.length
|
||
? `<table><thead><tr><th>工单号</th><th>来源</th><th>优先级</th><th>原因</th><th>状态</th></tr></thead><tbody>`
|
||
+ items.map((t) => `<tr><td><code>${esc(t.ticket_no)}</code></td><td>${esc(t.source_agent)}</td>
|
||
<td>${esc(t.priority)}</td><td>${esc(t.reason ?? '')}</td><td>${esc(t.status)}</td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: `<div class="muted">HTTP ${r.status}${r.status === 403 ? ' —— 需要 handover:read(admin 已授予)' : ''}</div>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function loadKnowledge() {
|
||
const r = await GET('/api/v1/knowledge/list');
|
||
const items = r.body?.data?.items || r.body?.data || [];
|
||
$('knowledge').innerHTML = Array.isArray(items) && items.length
|
||
? `<table><thead><tr><th>ID</th><th>标题</th><th>状态</th></tr></thead><tbody>`
|
||
+ items.slice(0, 30).map((k) => `<tr><td><code>${esc(k.knowledge_id ?? k.id)}</code></td>
|
||
<td>${esc(k.title ?? '')}</td><td>${esc(k.status ?? '')}</td></tr>`).join('')
|
||
+ '</tbody></table>'
|
||
: `<div class="muted">HTTP ${r.status}</div><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function admPromoLookup() {
|
||
const t = $('promo-task').value.trim();
|
||
if (!t) return bad('请填任务单号');
|
||
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
|
||
const mv = ((r.body || {}).data || {}).material_version || {};
|
||
if (mv.id) $('adm-version').value = mv.id;
|
||
$('promo-admin-extra').innerHTML = `<h2>任务 ${esc(t)}</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>(管理员不受"已投递"限制,能直接看到版本)</p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function admReview(decision) {
|
||
const t = $('promo-task').value.trim();
|
||
const vid = parseInt($('adm-version').value.trim(), 10);
|
||
if (!t) return bad('请填任务单号');
|
||
if (!vid) return bad('请填 material_version_id(可先点「查任务」自动填)');
|
||
const comment = $('adm-comment').value.trim() || null;
|
||
if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return;
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path:`/api/v1/fund-promotion-materials/${t}/reviews`,
|
||
body:{ material_version_id: vid, decision, comment } });
|
||
$('promo-admin-extra').innerHTML = `<h2>审核结果(${esc(decision)})</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b></p><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
/* ---------------- 投顾 ---------------- */
|
||
function renderAdvisor(box) {
|
||
box.innerHTML = `
|
||
<div class="panel">
|
||
<h2>投顾工作台</h2>
|
||
<p class="hint">面向 <code>advisor</code>。权限已补齐:工作流 + 行情/知识检索/客户画像
|
||
+ 推广材料 + NL2SQL。方案类接口需要真实产品披露文件与合同数据,缺数据时会明确报错而不是编造。</p>
|
||
<div class="row">
|
||
<input class="q" id="cust" placeholder="客户 ID,例如 9001">
|
||
<button class="act primary" onclick="goal()">查投资目标</button>
|
||
<button class="act" onclick="published()">已发布方案</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>组合分析 / 资产配置</h2>
|
||
<p class="hint">这两个接口的请求体是**空 JSON 对象** <code>{}</code>(schema 里
|
||
<code>additionalProperties: false</code>),多传字段会被判为报文非法。</p>
|
||
<div class="row">
|
||
<button class="act primary" onclick="portfolioAnalysis()">跑组合分析</button>
|
||
<button class="act" onclick="assetAllocation()">生成资产配置</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>推广材料(按流程走)</h2>
|
||
<p class="hint">这条线此前因为 <code>promotion:*</code> 四个权限码在库里根本不存在而整体 403,现已补齐。
|
||
<b>顺序不能跳</b>:创建 → <b>补结构化输入</b> → 生成 → <b>管理员审核</b> → 投递 → 才能查询。
|
||
跳过任何一步,查询都会返回「该材料尚未发送给当前投顾」—— 那是合规设计,不是故障。</p>
|
||
<div class="row">
|
||
<input class="q" id="promo-task" placeholder="任务单号 PM-..." style="width:220px">
|
||
<button class="act" onclick="promoGet()">查询任务</button>
|
||
<button class="act" onclick="promoChecks()">合规检查结果</button>
|
||
</div>
|
||
<div class="row">
|
||
<input class="q" id="promo-name" value="南方稳健增利债券A" placeholder="产品名称">
|
||
<input class="q" id="promo-title" value="稳健增利推介材料" placeholder="材料标题">
|
||
<input class="q" id="promo-style" value="steady_professional" placeholder="风格代码" style="width:160px">
|
||
<button class="act primary" onclick="promoCreate()">① 创建任务</button>
|
||
</div>
|
||
<div class="field">
|
||
<label>② 结构化输入(PUT inputs,六个块都必填;下面是骨架,把「待填写」补上再提交)
|
||
<button class="act" onclick="promoSaveInputs()">保存输入</button></label>
|
||
<textarea id="promo-inputs"></textarea>
|
||
</div>
|
||
<div class="row">
|
||
<button class="act" onclick="promoGenerate('pptx')">③ 生成 PPTX</button>
|
||
<button class="act" onclick="promoGenerate('pdf')">③ 生成 PDF</button>
|
||
<span class="muted">审核在「权限管理」页用管理员做(投顾没有 promotion:review —— 职责分离)</span>
|
||
</div>
|
||
<div class="row">
|
||
<input class="q" id="promo-version" placeholder="material_version_id" style="width:150px">
|
||
<input class="q" id="promo-advisors" value="9020" placeholder="投递给哪些投顾(逗号分隔 id)" style="width:260px">
|
||
<button class="act primary" onclick="promoDeliver()">⑤ 投递</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel" id="advisor-extra"></div>`;
|
||
goal();
|
||
promoSkeleton();
|
||
}
|
||
|
||
function promoSkeleton() {
|
||
// 两个刻意的选择:
|
||
// 1) 不预填任何业绩数字 —— `performance_info` 的业绩字段全部可选且带
|
||
// `show_product_performance` 开关,关掉即可;编造业绩是合规红线。
|
||
// 2) 费率七项**必须非空**:合规规则 `fee_structure.incomplete`(severity=block)
|
||
// 会拦下生成,任务会被置成 `compliance_failed`。这里填占位文本让它先跑通流程,
|
||
// 真实材料必须换成真实费率 —— 页面上也这么提示。
|
||
$('promo-inputs').value = JSON.stringify({
|
||
product_info: {
|
||
fund_type: "混合型",
|
||
operation_mode: "契约型开放式",
|
||
investment_objective: "在严格控制风险的前提下,力争实现基金资产的长期稳健增值",
|
||
benchmark: "沪深300指数收益率×60%+中债综合指数收益率×40%",
|
||
risk_level: "R3"
|
||
},
|
||
manager_info: {
|
||
management_company: "南方基金管理股份有限公司",
|
||
manager_name: "待填写",
|
||
registration_code: "待填写"
|
||
},
|
||
team_info: { team_description: "待填写" },
|
||
strategy_info: {
|
||
investment_scope: "本基金投资于国内依法发行上市的股票、债券等金融工具",
|
||
strategy: "自上而下的大类资产配置与自下而上的个股精选相结合",
|
||
restrictions: "不投资于法律法规禁止的标的"
|
||
},
|
||
fee_structure: {
|
||
subscription_fee: "待填写", purchase_fee: "待填写", redemption_fee: "待填写",
|
||
sales_service_fee: "待填写", management_fee: "待填写", custody_fee: "待填写",
|
||
client_maintenance_fee: "待填写"
|
||
},
|
||
performance_info: { show_product_performance: false, show_manager_performance: false },
|
||
risk_disclosure: { special_risks: ["市场风险", "流动性风险"], additional_notes: "详见基金合同与招募说明书" },
|
||
source_notes: {}
|
||
}, null, 2);
|
||
}
|
||
|
||
function promoTaskNo() {
|
||
const t = $('promo-task').value.trim();
|
||
if (!t) { bad('请先填任务单号(创建任务后会返回)'); return null; }
|
||
return t;
|
||
}
|
||
|
||
async function promoCreate() {
|
||
const name = $('promo-name').value.trim();
|
||
const title = $('promo-title').value.trim();
|
||
const style = $('promo-style').value.trim();
|
||
if (!name || !title || !style) return bad('产品名、材料标题、风格代码都要填');
|
||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials',
|
||
body:{ product_name: name, material_title: title, style_code: style } });
|
||
const d = (r.body || {}).data || {};
|
||
if (d.task_no) $('promo-task').value = d.task_no;
|
||
showAdv('① 创建任务' + (d.task_no ? ':' + d.task_no : ''), r);
|
||
}
|
||
|
||
async function promoSaveInputs() {
|
||
const t = promoTaskNo(); if (!t) return;
|
||
let body;
|
||
try { body = JSON.parse($('promo-inputs').value); }
|
||
catch (e) { return bad('结构化输入不是合法 JSON:' + e.message); }
|
||
const r = await jpost('/api/call', { method:'PUT',
|
||
path:`/api/v1/fund-promotion-materials/${t}/inputs`, body });
|
||
showAdv('② 保存结构化输入 ' + t, r);
|
||
}
|
||
|
||
async function promoGenerate(fmt) {
|
||
const t = promoTaskNo(); if (!t) return;
|
||
if (!confirm(`生成 ${fmt.toUpperCase()}?这一步会产出材料版本。`)) return;
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path:`/api/v1/fund-promotion-materials/${t}/generations`, body:{ output_formats:[fmt] } });
|
||
const body = r.body || {};
|
||
const d = body.data || {};
|
||
if (d.material_version_id) $('promo-version').value = d.material_version_id;
|
||
showAdv('③ 生成 ' + fmt.toUpperCase() + ' ' + t, r);
|
||
if (d.material_version_id) {
|
||
$('advisor-extra').insertAdjacentHTML('beforeend',
|
||
`<p class="hint">已产出 <code>material_version_id=${d.material_version_id}</code>,
|
||
状态 <b>pending_review</b>(所以此刻查询任务看不到它 —— get_task 只返回 approved/sent)。
|
||
下一步请<b>管理员</b>在「权限管理」页对它审核通过,之后你才能投递;版本号已自动填进上面的投递框。</p>`);
|
||
} else if (body.code) {
|
||
$('advisor-extra').insertAdjacentHTML('beforeend',
|
||
`<p class="hint bad">生成未成功(body.code=${esc(body.code)})。最常见的原因是
|
||
<b>费率七项没填全</b>:合规规则 <code>fee_structure.incomplete</code> 会把任务置成
|
||
compliance_failed。另注意:这个平台失败时也返回 HTTP 200,要看 body.code。</p>`);
|
||
}
|
||
}
|
||
|
||
async function promoDeliver() {
|
||
const t = promoTaskNo(); if (!t) return;
|
||
const versionId = parseInt($('promo-version').value.trim(), 10);
|
||
if (!versionId) return bad('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
|
||
const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x);
|
||
if (!ids.length) return bad('请填要投递的投顾 id');
|
||
if (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return;
|
||
const r = await jpost('/api/call', { method:'POST',
|
||
path:`/api/v1/fund-promotion-materials/${t}/deliveries`,
|
||
body:{ material_version_id: versionId, advisor_ids: ids, delivery_channel:'internal_record' } });
|
||
showAdv('⑤ 投递 ' + t, r);
|
||
}
|
||
|
||
async function promoGet() {
|
||
const t = promoTaskNo(); if (!t) return;
|
||
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
|
||
const err = ((r.body || {}).error) || {};
|
||
let extra = '';
|
||
if (r.status === 404 && String(err.message || '').includes('尚未发送')) {
|
||
extra = ' —— 材料存在但还没投递给你:请确认已完成「补输入 → 生成 → 管理员审核 → 投递」。'
|
||
+ '版本号可以在下面的原始返回里找,或让管理员查这个任务。';
|
||
}
|
||
showAdv('查询任务 ' + t, r);
|
||
if (extra) $('advisor-extra').insertAdjacentHTML('beforeend', `<p class="hint">${esc(extra)}</p>`);
|
||
}
|
||
|
||
async function promoChecks() {
|
||
const t = promoTaskNo(); if (!t) return;
|
||
showAdv('合规检查 ' + t,
|
||
await GET(`/api/v1/fund-promotion-materials/${t}/compliance-checks`));
|
||
}
|
||
|
||
async function portfolioAnalysis() {
|
||
if (!confirm('跑一次组合分析?(请求体为空对象)')) return;
|
||
showAdv('组合分析', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/advisor/portfolio-analysis', body:{} }));
|
||
}
|
||
|
||
async function assetAllocation() {
|
||
if (!confirm('生成一次资产配置?(请求体为空对象)')) return;
|
||
showAdv('资产配置', await jpost('/api/call',
|
||
{ method:'POST', path:'/api/v1/advisor/asset-allocation', body:{} }));
|
||
}
|
||
|
||
function showAdv(title, r) {
|
||
$('advisor-extra').innerHTML = result(title, r);
|
||
}
|
||
|
||
// 统一的结果渲染:**必须看 body.code**,不能只看 HTTP 状态。
|
||
// 这个平台把业务失败也放在 HTTP 200 里(例如生成材料失败是
|
||
// `HTTP 200 + body.code=422 + message=材料内容未通过合规校验`),
|
||
// 只看 HTTP 会把它显示成成功。
|
||
function result(title, r) {
|
||
const body = (r && r.body) || {};
|
||
const code = body.code;
|
||
const err = body.error || {};
|
||
const biz = (code === undefined || code === null) ? null : Number(code);
|
||
const isBad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
|
||
const bits = [`HTTP <b>${r.status}</b>`];
|
||
if (biz !== null) bits.push(`body.code <b>${biz}</b>`);
|
||
if (err.code) bits.push(`error <b>${esc(err.code)}</b>`);
|
||
let extra = '';
|
||
if (err.message) extra = esc(err.message);
|
||
else if (isBad && body.message) extra = esc(body.message);
|
||
if (!isBad) {
|
||
if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
|
||
else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
|
||
}
|
||
return `<h2>${esc(title)}</h2>
|
||
<p class="hint ${isBad ? 'bad' : ''}">${bits.join(' · ')}${extra ? ' —— ' + extra : ''}</p>
|
||
<pre>${esc(pretty(body))}</pre>`;
|
||
}
|
||
|
||
async function goal() {
|
||
// 投顾要看的是**客户**的目标,所以走 `customers/{id}` 变体 —— 服务层会按
|
||
// `customer_id == 自己` 决定拼 `:self` 还是 `:customer` 权限码,后者要求
|
||
// `data_scope=own_customers` 且该客户确实在这个投顾名下,否则返回 404。
|
||
const id = ($('cust')?.value.trim() || '9001');
|
||
const path = `/api/v1/advisor/customers/${id}/investment-goals/current`;
|
||
const r = await GET(path);
|
||
const hint = r.status === 403
|
||
? ' —— 需要 investment-goal:read:customer(scope=own_customers)'
|
||
: (r.status === 404 ? ' —— 该客户不在你名下,或还没有投资目标' : '');
|
||
$('advisor-extra').innerHTML = `<h2>投资目标</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b>${hint} · <code>${esc(path)}</code></p>
|
||
<pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
async function published() {
|
||
const r = await GET('/api/v1/advisor/recommendations/published');
|
||
$('advisor-extra').innerHTML = `<h2>已发布方案</h2>
|
||
<p class="hint">HTTP <b>${r.status}</b></p><pre>${esc(pretty(r.body))}</pre>`;
|
||
}
|
||
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
#: 页面构建标记:取本文件的修改时间。刷新后这个值应当变化 ——
|
||
#: 用它一眼确认浏览器加载的不是缓存旧页(旧页缺 Toast/模态容器时,点击会静默失效)。
|
||
_PAGE_BUILD = datetime.fromtimestamp(Path(__file__).stat().st_mtime).strftime("%m-%d %H:%M")
|
||
PAGE = PAGE.replace("__BUILD__", _PAGE_BUILD)
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="统一登录门户(按角色分流)")
|
||
parser.add_argument("--port", type=int, default=8101)
|
||
parser.add_argument("--base-url", default=None, help="指向已在运行的服务;省略则进程内直挂平台 app")
|
||
args = parser.parse_args()
|
||
|
||
state = Portal(args.base_url)
|
||
mode = args.base_url or "进程内(走真实中间件与鉴权栈)"
|
||
print(f"登录门户:http://127.0.0.1:{args.port} 模式={mode}")
|
||
print("演示账号:cust_t/123456 · risk_t/666666 · admin_t/88888888 · advisor_t/abc12345")
|
||
print("客服对话前请停掉常驻 Worker;账号需先跑 seed_test_rbac.py + set_user_password.py。")
|
||
uvicorn.run(build_portal(state), host="127.0.0.1", port=args.port, log_level="warning")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|