Compare commits
10 Commits
0501e6e8c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf2991382d | |||
| 49d5e947c2 | |||
| eb4c8e8ba3 | |||
| 87e0a931ba | |||
| 0fd8489c22 | |||
| 5ae33502f9 | |||
| 8bac14ef44 | |||
| c715dbb306 | |||
| f42c04da8b | |||
| 3716564b58 |
@@ -7,3 +7,7 @@ coverage/
|
||||
.env
|
||||
.env.local
|
||||
deploy.env
|
||||
|
||||
# 演示视频工具与产物(不纳入版本库)
|
||||
demo-video-kit/
|
||||
demo-video-out/
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
// 外包项目风险评估系统 — 详细使用演示视频配置
|
||||
// 运行:AI_API_KEY=<key> node demo-video-kit/src/cli.mjs demo.config.mjs --out demo-video-out
|
||||
//
|
||||
// 设计:多角色走查(销售 → 风控 → 管理层 → 系统管理员),以生产环境真实数据为准,
|
||||
// 全程只读浏览 + 展示新建向导前两步(不实际运行,避免产生脏数据;演示草稿事后清理)。
|
||||
|
||||
const PASS = '123456';
|
||||
const ADMIN_PASS = 'Abc123456';
|
||||
// 已完成(approved)评估,数据完整,用于详情逐区讲解。
|
||||
const DETAIL_ID = 'assessment-mq8wvenq-sou59jrj';
|
||||
|
||||
/** 登录指定用户(SPA,无整页刷新;字幕浮层保留)。 */
|
||||
async function login(page, username, password, pause) {
|
||||
// 已登录则先退出,回到登录页。
|
||||
const logoutBtn = page.getByRole('button', { name: '退出' });
|
||||
if (await logoutBtn.count()) {
|
||||
await logoutBtn.first().click();
|
||||
await pause(1500);
|
||||
}
|
||||
await page.getByPlaceholder('请输入用户名').fill(username);
|
||||
await page.getByPlaceholder('请输入密码').fill(password);
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click();
|
||||
await pause(2600);
|
||||
}
|
||||
|
||||
/** 点击顶部导航(data-nav-link 跨度元素)。 */
|
||||
async function nav(page, text, pause) {
|
||||
await page.locator('[data-nav-link]', { hasText: text }).first().click();
|
||||
await pause(1800);
|
||||
}
|
||||
|
||||
/** 展开详情页所有可折叠分区,便于完整呈现。 */
|
||||
async function expandAll(page) {
|
||||
for (let i = 0; i < 18; i += 1) {
|
||||
const collapsed = page.locator('button[aria-expanded="false"]');
|
||||
if ((await collapsed.count()) === 0) break;
|
||||
try { await collapsed.first().click({ timeout: 4000 }); } catch { break; }
|
||||
await page.waitForTimeout(180);
|
||||
}
|
||||
}
|
||||
|
||||
/** 平滑滚动到指定纵向位置。 */
|
||||
async function scrollTo(page, y) {
|
||||
await page.evaluate((yy) => window.scrollTo({ top: yy, behavior: 'smooth' }), y);
|
||||
}
|
||||
|
||||
export default {
|
||||
baseUrl: 'https://pm.hr8ai.top',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
brand: '外包项目风险评估系统',
|
||||
outDir: 'demo-video-out',
|
||||
gotoTimeout: 60000,
|
||||
subtitles: false, // 不在画面上渲染字幕(仍按解说时间轴配音)
|
||||
|
||||
voice: { name: 'Cherry', model: 'qwen-tts', apiKeyEnv: 'AI_API_KEY', fallbackVoice: 'Tingting' },
|
||||
|
||||
intro: {
|
||||
narration:
|
||||
'欢迎使用外包项目风险评估系统。它把人工智能的语言理解与确定性的风控引擎结合,' +
|
||||
'帮助企业在承接外包项目前,自动完成风险评分、红线校验与盈利测算,并支持多角色的分级审批。',
|
||||
},
|
||||
outro: {
|
||||
narration:
|
||||
'以上就是从销售申报、风控审核、管理层审批到系统管理的完整流程演示。' +
|
||||
'评分、分级、红线与盈利测算均为确定性计算,全程留痕可审计。感谢观看。',
|
||||
},
|
||||
|
||||
steps: [
|
||||
// ---------------- 销售视角 ----------------
|
||||
{
|
||||
label: '销售登录',
|
||||
narration:
|
||||
'首先以销售人员张伟的身份登录。系统采用角色化的权限管理,不同角色登录后看到的工作台与可用功能各不相同。',
|
||||
run: async ({ page, pause }) => {
|
||||
await login(page, '张伟', PASS, pause);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '销售工作台',
|
||||
narration:
|
||||
'这是销售的工作台。上方是我的评估统计,下方可以看到我发起的项目、审批进度,以及未完成的向导草稿。销售只能看到本人发起的评估,实现了数据隔离。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 0);
|
||||
await pause(2000);
|
||||
await scrollTo(page, 360);
|
||||
await pause(1500);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '新建评估-立项',
|
||||
narration:
|
||||
'点击新建评估,进入六步录入向导。第一步填写立项信息:项目名称、客户名称与适用地域。客户名称会自动匹配信用档案,影响后续的坏账计提。',
|
||||
run: async ({ page, pause }) => {
|
||||
await nav(page, '新建评估', pause);
|
||||
await page.getByPlaceholder('如 某银行信用卡客服 BPO').fill('某制造企业生产线劳务外包项目(演示)');
|
||||
await page.getByPlaceholder('客户公司全称').fill('某大型制造企业(演示)');
|
||||
await page.locator('select').first().selectOption('上海').catch(() => {});
|
||||
await pause(1800);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '新建评估-描述',
|
||||
narration:
|
||||
'第二步填写项目描述。系统会调用大模型解析这段文字,自动识别业务类型、所属行业,并为风险指标给出预填建议,销售只需确认或微调,大幅降低录入成本。',
|
||||
run: async ({ page, pause }) => {
|
||||
await page.getByRole('button', { name: '下一步', exact: true }).click().catch(() => {});
|
||||
await pause(1200);
|
||||
await page.locator('textarea').first().fill(
|
||||
'为客户提供生产线普工与质检岗位的劳务外包,约一百二十人,两班倒;' +
|
||||
'按人头费率结算,账期约两个月;需符合最低工资与社保合规要求,涉及驻场管理与考勤。',
|
||||
).catch(() => {});
|
||||
await pause(2200);
|
||||
},
|
||||
},
|
||||
|
||||
// ---------------- 风控视角 ----------------
|
||||
{
|
||||
label: '风控登录',
|
||||
narration:
|
||||
'接下来以风控专员刘洋的身份登录,查看需要审核的项目。',
|
||||
run: async ({ page, pause }) => {
|
||||
await login(page, '刘洋', PASS, pause);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '风控待办',
|
||||
narration:
|
||||
'风控工作台的核心是待办审核列表,列出了所有报送到风控、等待审阅的项目,以及风险分、分级与承接建议。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 0);
|
||||
await pause(2200);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '评估详情-结论',
|
||||
narration:
|
||||
'我们打开一个已完成的评估详情。页面顶部是核心结论:风险总分、风险分级、可接受性与承接建议,一目了然。',
|
||||
run: async ({ page, pause }) => {
|
||||
await page.goto('https://pm.hr8ai.top/assessments/' + DETAIL_ID, { waitUntil: 'domcontentloaded' });
|
||||
await pause(2500);
|
||||
await expandAll(page);
|
||||
await scrollTo(page, 0);
|
||||
await pause(1500);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '详情-风险评分',
|
||||
narration:
|
||||
'向下是风险评分明细。系统按多个维度的指标加权计算总分,并标注每项数据的来源——是用户输入、智能体假设,还是外部数据,保证评分过程完全可解释。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 620);
|
||||
await pause(2600);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '详情-红线校验',
|
||||
narration:
|
||||
'红线校验区会列出命中的合规红线,例如派遣比例超标、低于最低工资等。需要人工核实的项,风控可以在这里直接裁定。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 1240);
|
||||
await pause(2600);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '详情-盈利分析',
|
||||
narration:
|
||||
'盈利分析区基于全口径成本——工资、社保、公积金、福利与各项摊销,测算月度与合同期的毛利、净利、税费与风险准备金,收入则按报价模式确定性计算。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 1860);
|
||||
await pause(2800);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '详情-现金流',
|
||||
narration:
|
||||
'现金流与垫资峰值,反映外包先垫付成本、账期后才收款的资金压力,帮助判断项目的现金占用与融资成本。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 2480);
|
||||
await pause(2600);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '详情-定价曲线',
|
||||
narration:
|
||||
'盈亏平衡与定价空间曲线,直观展示在不同报价水平下的净利率,给出安全的降价空间,为商务谈判提供依据。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 3100);
|
||||
await pause(2600);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '详情-审计时间线',
|
||||
narration:
|
||||
'页面底部是完整的操作时间线,记录从发起、风控审核到管理层审批的每一步,全程留痕,可供合规追溯。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, await page.evaluate(() => document.body.scrollHeight - 900));
|
||||
await pause(2600);
|
||||
},
|
||||
},
|
||||
|
||||
// ---------------- 管理层视角 ----------------
|
||||
{
|
||||
label: '管理层登录',
|
||||
narration:
|
||||
'再以管理层赵磊的身份登录。管理层负责终审,并维护费率、红线与客户档案等基础数据。',
|
||||
run: async ({ page, pause }) => {
|
||||
await page.goto('https://pm.hr8ai.top/', { waitUntil: 'domcontentloaded' });
|
||||
await pause(1500);
|
||||
await login(page, '赵磊', PASS, pause);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '管理层看板',
|
||||
narration:
|
||||
'管理层看板提供组合分析:预测准确度对比预测与实际净利率,给出系统性偏差并支持一键校准;还有驳回原因排行、到期复评与审批超时提醒。',
|
||||
run: async ({ page, pause }) => {
|
||||
await scrollTo(page, 0);
|
||||
await pause(2200);
|
||||
await scrollTo(page, 420);
|
||||
await pause(2200);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '费率管理',
|
||||
narration:
|
||||
'费率管理把社保、公积金、增值税等费率从代码中解放出来,按地域可配置、可复核、可版本化,盈利测算据此计算。',
|
||||
run: async ({ page, pause }) => {
|
||||
await nav(page, '费率管理', pause);
|
||||
await pause(1800);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '红线管理',
|
||||
narration:
|
||||
'红线管理是可配置的合规规则库,可以按地域和业务类型启用规则、绑定度量与阈值,让红线在评估时自动判定命中。',
|
||||
run: async ({ page, pause }) => {
|
||||
await nav(page, '红线管理', pause);
|
||||
await pause(1800);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '客户档案',
|
||||
narration:
|
||||
'客户档案维护客户的信用评级、逾期与集中度。集中度过高会自动预警,信用评级则联动坏账准备金的计提比例。',
|
||||
run: async ({ page, pause }) => {
|
||||
await nav(page, '客户档案', pause);
|
||||
await pause(1800);
|
||||
await scrollTo(page, 360);
|
||||
await pause(1500);
|
||||
},
|
||||
},
|
||||
|
||||
// ---------------- 系统管理员视角 ----------------
|
||||
{
|
||||
label: '管理员登录',
|
||||
narration:
|
||||
'最后以系统管理员周强的身份登录,负责用户、审批流程与系统日志的管理。',
|
||||
run: async ({ page, pause }) => {
|
||||
await page.goto('https://pm.hr8ai.top/', { waitUntil: 'domcontentloaded' });
|
||||
await pause(1500);
|
||||
await login(page, '周强', ADMIN_PASS, pause);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '用户管理',
|
||||
narration:
|
||||
'用户管理可以新增、停用账号并分配角色。每个人都有稳定的用户编号,系统用它在各类记录中保持关联,界面再实时解析成姓名。',
|
||||
run: async ({ page, pause }) => {
|
||||
await page.goto('https://pm.hr8ai.top/users', { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await pause(2200);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '审批流程',
|
||||
narration:
|
||||
'审批流程页配置审批规则与审批线:哪些项目需要管理层终审、各角色的服务时限,以及销售归属的风控与管理层。',
|
||||
run: async ({ page, pause }) => {
|
||||
await nav(page, '审批流程', pause);
|
||||
await pause(2000);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '日志管理',
|
||||
narration:
|
||||
'日志管理记录系统的全部操作——登录登出、申报审批、配置变更乃至报告导出,支持按操作人、角色、动作、方法、结果和时间范围多维筛选,满足审计追溯需求。',
|
||||
run: async ({ page, pause }) => {
|
||||
await nav(page, '日志管理', pause);
|
||||
await pause(2200);
|
||||
await scrollTo(page, 300);
|
||||
await pause(1800);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Binary file not shown.
@@ -43,6 +43,8 @@ export interface SystemLogQuery {
|
||||
offset: number;
|
||||
actorId?: string;
|
||||
action?: string;
|
||||
role?: string;
|
||||
method?: string;
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -58,13 +60,15 @@ export async function querySystemLogs(
|
||||
const params: unknown[] = [];
|
||||
if (opts.actorId !== undefined && opts.actorId !== '') { params.push(opts.actorId); where.push(`actor_id = $${params.length}`); }
|
||||
if (opts.action !== undefined && opts.action !== '') { params.push(opts.action); where.push(`action = $${params.length}`); }
|
||||
if (opts.role !== undefined && opts.role !== '') { params.push(opts.role); where.push(`role = $${params.length}`); }
|
||||
if (opts.method !== undefined && opts.method !== '') { params.push(opts.method); where.push(`method = $${params.length}`); }
|
||||
if (opts.success !== undefined) { params.push(opts.success); where.push(`success = $${params.length}`); }
|
||||
if (opts.from !== undefined && opts.from !== '') { params.push(opts.from); where.push(`ts >= $${params.length}`); }
|
||||
if (opts.to !== undefined && opts.to !== '') { params.push(opts.to); where.push(`ts <= $${params.length}`); }
|
||||
if (opts.q !== undefined && opts.q.trim() !== '') {
|
||||
params.push(`%${opts.q.trim()}%`);
|
||||
const i = params.length;
|
||||
where.push(`(path ILIKE $${i} OR actor_name ILIKE $${i} OR action ILIKE $${i} OR target_id ILIKE $${i})`);
|
||||
where.push(`(path ILIKE $${i} OR actor_name ILIKE $${i} OR action ILIKE $${i} OR target_id ILIKE $${i} OR target_name ILIKE $${i})`);
|
||||
}
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
@@ -105,3 +109,20 @@ export async function distinctActions(pool: pg.Pool): Promise<string[]> {
|
||||
const res = await pool.query('SELECT DISTINCT action FROM system_logs ORDER BY action');
|
||||
return (res.rows as Array<{ action: string }>).map((r) => String(r.action));
|
||||
}
|
||||
|
||||
/** 已出现过的角色(供筛选下拉)。 */
|
||||
export async function distinctRoles(pool: pg.Pool): Promise<string[]> {
|
||||
const res = await pool.query("SELECT DISTINCT role FROM system_logs WHERE role IS NOT NULL AND role <> '' ORDER BY role");
|
||||
return (res.rows as Array<{ role: string }>).map((r) => String(r.role));
|
||||
}
|
||||
|
||||
/** 已出现过的操作人(id+姓名,供筛选下拉)。 */
|
||||
export async function distinctActors(pool: pg.Pool): Promise<Array<{ id: string; name: string }>> {
|
||||
const res = await pool.query(
|
||||
"SELECT actor_id, actor_name FROM system_logs WHERE actor_id IS NOT NULL AND actor_name IS NOT NULL GROUP BY actor_id, actor_name ORDER BY actor_name",
|
||||
);
|
||||
return (res.rows as Array<{ actor_id: string; actor_name: string }>).map((r) => ({
|
||||
id: String(r.actor_id),
|
||||
name: String(r.actor_name),
|
||||
}));
|
||||
}
|
||||
|
||||
+71
-11
@@ -77,6 +77,8 @@ import {
|
||||
insertSystemLog,
|
||||
querySystemLogs,
|
||||
distinctActions,
|
||||
distinctRoles,
|
||||
distinctActors,
|
||||
getApprovalConfig,
|
||||
saveApprovalConfig,
|
||||
ensureApprovalConfig,
|
||||
@@ -148,6 +150,7 @@ function deriveActionLabel(method: string, path: string): string {
|
||||
const p = path.replace(/\/+$/, '');
|
||||
const rules: ReadonlyArray<[RegExp, string]> = [
|
||||
[/^\/api\/auth\/login$/, '登录'],
|
||||
[/^\/api\/auth\/logout$/, '登出'],
|
||||
[/^\/api\/assessments\/run$/, '运行评估(创建/重评)'],
|
||||
[/^\/api\/assessments\/[^/]+\/submit$/, '申报报送风控'],
|
||||
[/^\/api\/assessments\/[^/]+\/resubmit$/, '驳回后重新提交'],
|
||||
@@ -190,15 +193,40 @@ function deriveTargetId(path: string): string | null {
|
||||
return m && m[1] !== undefined ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
// 系统操作审计:记录全部写操作(POST/PUT/DELETE)+ 登录,供系统管理员审计。
|
||||
/**
|
||||
* 关键只读操作的审计标签(GET)。仅记录有审计价值的读操作(导出/查看报告/查看评估详情),
|
||||
* 跳过看板轮询、列表与聚合等高频只读端点(summary/expiring/overdue/accuracy/calibration 等),
|
||||
* 避免审计日志被噪音淹没。返回 null 表示该 GET 不记录。
|
||||
*/
|
||||
function deriveReadActionLabel(path: string): string | null {
|
||||
if (/^\/api\/assessments\/[^/]+\/report\/export$/.test(path)) return '导出报告';
|
||||
if (/^\/api\/assessments\/[^/]+\/report$/.test(path)) return '查看报告';
|
||||
// 单条评估详情查看(排除聚合/列表类只读端点)。
|
||||
if (/^\/api\/assessments\/[^/]+$/.test(path)) {
|
||||
const id = path.split('/')[3] ?? '';
|
||||
const skip = ['summary', 'expiring', 'overdue', 'run', 'profitability'];
|
||||
if (id !== '' && !skip.includes(id)) return '查看评估详情';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 系统操作审计:记录全部写操作(POST/PUT/DELETE)+ 登录 + 关键只读操作(导出/查看报告/查看详情),供系统管理员审计。
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const method = c.req.method;
|
||||
const start = Date.now();
|
||||
await next();
|
||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return;
|
||||
if (method === 'HEAD' || method === 'OPTIONS') return;
|
||||
if (pool === null) return;
|
||||
const path = c.req.path;
|
||||
if (path === '/api/system-logs') return; // 不记录查询日志自身
|
||||
|
||||
// GET:仅记录有审计价值的关键只读操作,其余(看板轮询/列表/聚合)跳过。
|
||||
let readLabel: string | null = null;
|
||||
if (method === 'GET') {
|
||||
readLabel = deriveReadActionLabel(path);
|
||||
if (readLabel === null) return;
|
||||
}
|
||||
|
||||
const payload = (c as import('hono').Context).get('user') as AuthPayload | undefined;
|
||||
let actorId = payload?.uid ?? null;
|
||||
let actorName = payload?.username ?? null;
|
||||
@@ -216,7 +244,7 @@ app.use('/api/*', async (c, next) => {
|
||||
}
|
||||
|
||||
// 业务动作增强:解析目标项目名 + 决策(通过/驳回等)。
|
||||
let action = deriveActionLabel(method, path);
|
||||
let action = method === 'GET' ? readLabel as string : deriveActionLabel(method, path);
|
||||
let targetName: string | null = null;
|
||||
const targetId = deriveTargetId(path);
|
||||
const isAssessmentPath = /^\/api\/assessments\/[^/]+/.test(path) && targetId !== null && targetId !== 'run' && targetId !== 'profitability' && targetId !== 'summary' && targetId !== 'expiring' && targetId !== 'overdue';
|
||||
@@ -292,6 +320,15 @@ app.post('/api/auth/login', async (c) => {
|
||||
return c.json({ token, username, role });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* 登出:无状态 JWT 由客户端丢弃 token 即可,本端点仅用于留痕审计(记录谁/何时登出)。
|
||||
* 需携带有效 token,审计中间件据此解析操作人。
|
||||
*/
|
||||
app.post('/api/auth/logout', (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** LLM 启用状态(不泄露密钥),供前端/调试查询。 */
|
||||
app.get('/api/llm/status', (c) => {
|
||||
const cfg = getLlmConfig();
|
||||
@@ -356,6 +393,18 @@ function targetNetMargin(): number {
|
||||
return Number.isFinite(v) && v > 0 ? v : DEFAULT_TARGET_NET_MARGIN;
|
||||
}
|
||||
|
||||
/** 未校准的原始目标净利率基准(env/默认,忽略校准覆盖)。校准始终基于此基准做一次性补偿,保证幂等。 */
|
||||
function uncalibratedTargetNetMargin(): number {
|
||||
const v = Number(process.env.TARGET_NET_MARGIN);
|
||||
return Number.isFinite(v) && v > 0 ? v : DEFAULT_TARGET_NET_MARGIN;
|
||||
}
|
||||
|
||||
/** 据系统性偏差计算建议的目标净利率基准(基于未校准基准,夹取 [2%,30%])。 */
|
||||
function suggestedTargetBase(deviationPct: number): number {
|
||||
const base = uncalibratedTargetNetMargin();
|
||||
return Math.min(0.3, Math.max(0.02, Math.round((base + deviationPct / 100) * 1000) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/assessments/classify
|
||||
* 输入项目描述,返回业务类型与行业识别结果。
|
||||
@@ -883,6 +932,7 @@ app.get('/api/accuracy', async (c) => {
|
||||
*/
|
||||
app.get('/api/calibration', async (c) => {
|
||||
const current = targetNetMargin();
|
||||
const uncalibrated = uncalibratedTargetNetMargin();
|
||||
let suggested = current;
|
||||
let bias: string | null = null;
|
||||
let deviationPct: number | null = null;
|
||||
@@ -891,16 +941,18 @@ app.get('/api/calibration', async (c) => {
|
||||
bias = acc.bias;
|
||||
deviationPct = acc.avgDeviationPct;
|
||||
if (acc.avgDeviationPct !== null && acc.count > 0) {
|
||||
// 预测偏乐观(dev>0)→ 抬高要求的目标净利率以补偿;偏保守则下调。夹取 [2%,30%]。
|
||||
suggested = Math.min(0.3, Math.max(0.02, Math.round((current + acc.avgDeviationPct / 100) * 1000) / 1000));
|
||||
// 预测偏乐观(dev>0)→ 抬高要求的目标净利率以补偿;偏保守则下调。基于未校准基准,保证幂等。
|
||||
suggested = suggestedTargetBase(acc.avgDeviationPct);
|
||||
}
|
||||
}
|
||||
return c.json({ currentBase: current, suggestedBase: suggested, bias, deviationPct });
|
||||
// 已校准:当前基准已等于(或非常接近)据当前偏差计算的建议基准,无需再次应用。
|
||||
const calibrated = calibratedTargetBase !== null && Math.abs(current - suggested) < 1e-6;
|
||||
return c.json({ currentBase: current, suggestedBase: suggested, uncalibratedBase: uncalibrated, calibrated, bias, deviationPct });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/calibration/apply
|
||||
* 应用校准:将目标净利率基准设为据预测偏差计算的建议值(管理层)。
|
||||
* 应用校准:将目标净利率基准设为据预测偏差计算的建议值(管理层)。基于未校准基准,幂等。
|
||||
*/
|
||||
app.post('/api/calibration/apply', requireRole('管理层'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
@@ -909,7 +961,7 @@ app.post('/api/calibration/apply', requireRole('管理层'), async (c) => {
|
||||
return c.json({ error: '暂无足够的实际值回填数据用于校准' }, 400);
|
||||
}
|
||||
const current = targetNetMargin();
|
||||
const next = Math.min(0.3, Math.max(0.02, Math.round((current + acc.avgDeviationPct / 100) * 1000) / 1000));
|
||||
const next = suggestedTargetBase(acc.avgDeviationPct);
|
||||
await setSetting(pool, 'targetMarginBase', next);
|
||||
calibratedTargetBase = next;
|
||||
return c.json({ appliedBase: next, previousBase: current, deviationPct: acc.avgDeviationPct });
|
||||
@@ -1425,12 +1477,14 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
||||
|
||||
/** 分页查询系统操作日志(系统管理员)。 */
|
||||
app.get('/api/system-logs', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ items: [], total: 0, page: 1, pageSize: 20, actions: [] });
|
||||
if (pool === null) return c.json({ items: [], total: 0, page: 1, pageSize: 20, actions: [], roles: [], actors: [] });
|
||||
const page = Math.max(1, Number(c.req.query('page') ?? 1) || 1);
|
||||
const pageSize = Math.max(1, Math.min(Number(c.req.query('pageSize') ?? 20) || 20, 200));
|
||||
const successQ = c.req.query('success');
|
||||
const fActor = c.req.query('actorId');
|
||||
const fAction = c.req.query('action');
|
||||
const fRole = c.req.query('role');
|
||||
const fMethod = c.req.query('method');
|
||||
const fQ = c.req.query('q');
|
||||
const fFrom = c.req.query('from');
|
||||
const fTo = c.req.query('to');
|
||||
@@ -1439,13 +1493,19 @@ app.get('/api/system-logs', requireRole('系统管理员'), async (c) => {
|
||||
offset: (page - 1) * pageSize,
|
||||
...(fActor ? { actorId: fActor } : {}),
|
||||
...(fAction ? { action: fAction } : {}),
|
||||
...(fRole ? { role: fRole } : {}),
|
||||
...(fMethod ? { method: fMethod } : {}),
|
||||
...(fQ ? { q: fQ } : {}),
|
||||
...(fFrom ? { from: fFrom } : {}),
|
||||
...(fTo ? { to: fTo } : {}),
|
||||
...(successQ === 'true' || successQ === 'false' ? { success: successQ === 'true' } : {}),
|
||||
});
|
||||
const actions = await distinctActions(pool).catch(() => []);
|
||||
return c.json({ items, total, page, pageSize, actions });
|
||||
const [actions, roles, actors] = await Promise.all([
|
||||
distinctActions(pool).catch(() => []),
|
||||
distinctRoles(pool).catch(() => []),
|
||||
distinctActors(pool).catch(() => []),
|
||||
]);
|
||||
return c.json({ items, total, page, pageSize, actions, roles, actors });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
|
||||
+18
-1
@@ -566,6 +566,18 @@ export async function applyCalibration(): Promise<{ appliedBase: number; previou
|
||||
return request('POST', '/api/calibration/apply', {});
|
||||
}
|
||||
|
||||
/** 查询当前目标净利率基准与据偏差计算的建议基准、是否已校准。 */
|
||||
export async function fetchCalibration(): Promise<{
|
||||
currentBase: number;
|
||||
suggestedBase: number;
|
||||
uncalibratedBase: number;
|
||||
calibrated: boolean;
|
||||
bias: string | null;
|
||||
deviationPct: number | null;
|
||||
}> {
|
||||
return request('GET', '/api/calibration');
|
||||
}
|
||||
|
||||
/** 风控/管理层对「待核实」红线进行人工裁定(命中/未命中),闭环判定。 */
|
||||
export async function submitRedlineVerdict(
|
||||
id: string,
|
||||
@@ -1008,16 +1020,21 @@ export interface SystemLogPage {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
actions: string[];
|
||||
roles: string[];
|
||||
actors: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/** 查询系统操作审计日志(系统管理员)。 */
|
||||
export async function fetchSystemLogs(params: {
|
||||
page: number; pageSize: number; actorId?: string; action?: string; q?: string; from?: string; to?: string; success?: 'true' | 'false';
|
||||
page: number; pageSize: number; actorId?: string; action?: string; role?: string; method?: string; q?: string; from?: string; to?: string; success?: 'true' | 'false';
|
||||
}): Promise<SystemLogPage> {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(params.page));
|
||||
sp.set('pageSize', String(params.pageSize));
|
||||
if (params.action) sp.set('action', params.action);
|
||||
if (params.actorId) sp.set('actorId', params.actorId);
|
||||
if (params.role) sp.set('role', params.role);
|
||||
if (params.method) sp.set('method', params.method);
|
||||
if (params.q) sp.set('q', params.q);
|
||||
if (params.from) sp.set('from', params.from);
|
||||
if (params.to) sp.set('to', params.to);
|
||||
|
||||
@@ -90,54 +90,56 @@ export function Table<T>({
|
||||
});
|
||||
|
||||
return (
|
||||
<table style={tableStyle}>
|
||||
{caption !== undefined ? (
|
||||
<caption
|
||||
style={{
|
||||
...typographyStyle('caption'),
|
||||
color: colorVar('color.text.secondary'),
|
||||
textAlign: 'left',
|
||||
paddingBottom: `${space(2)}px`,
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</caption>
|
||||
) : null}
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} scope="col" style={thStyle(column.align ?? 'left')}>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<div style={{ width: '100%', overflowX: 'auto' }}>
|
||||
<table style={tableStyle}>
|
||||
{caption !== undefined ? (
|
||||
<caption
|
||||
style={{
|
||||
...typographyStyle('caption'),
|
||||
color: colorVar('color.text.secondary'),
|
||||
textAlign: 'left',
|
||||
paddingBottom: `${space(2)}px`,
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</caption>
|
||||
) : null}
|
||||
<thead>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
style={{
|
||||
...tdStyle('center'),
|
||||
color: colorVar('color.text.secondary'),
|
||||
padding: `${space(5)}px ${space(3)}px`,
|
||||
}}
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} scope="col" style={thStyle(column.align ?? 'left')}>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr key={getRowKey(row, index)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={tdStyle(column.align ?? 'left')}>
|
||||
{cellContent(column, row)}
|
||||
</td>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
style={{
|
||||
...tdStyle('center'),
|
||||
color: colorVar('color.text.secondary'),
|
||||
padding: `${space(5)}px ${space(3)}px`,
|
||||
}}
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr key={getRowKey(row, index)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={tdStyle(column.align ?? 'left')}>
|
||||
{cellContent(column, row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+41
-16
@@ -17,7 +17,10 @@ import {
|
||||
space,
|
||||
typographyStyle,
|
||||
} from '../design-system/components/styles.js';
|
||||
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js';
|
||||
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, fetchCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js';
|
||||
|
||||
// 校准状态类型(目标净利率基准)。
|
||||
type CalibrationState = { currentBase: number; suggestedBase: number; uncalibratedBase: number; calibrated: boolean; bias: string | null; deviationPct: number | null };
|
||||
import type { AssessmentListItem, WorkflowStatus, DraftItem, AssignmentRecord } from '../api/client.js';
|
||||
import { useAuthStore } from '../stores/authStore.js';
|
||||
import { GuideBanner } from '../app/Guidance.js';
|
||||
@@ -116,6 +119,8 @@ export function Dashboard(): JSX.Element {
|
||||
const [overdue, setOverdue] = useState<Array<{ id: string; project: string; overdueHours: number }>>([]);
|
||||
const [rejectStats, setRejectStats] = useState<Array<{ reasonType: string; count: number }>>([]);
|
||||
const [accuracy, setAccuracy] = useState<{ count: number; avgPredictedPct: number | null; avgActualPct: number | null; avgDeviationPct: number | null; bias: string | null }>({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
|
||||
// 校准状态(目标净利率基准)。
|
||||
const [calibration, setCalibration] = useState<CalibrationState | null>(null);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
@@ -157,12 +162,13 @@ export function Dashboard(): JSX.Element {
|
||||
}
|
||||
// 组合分析(准确度/驳回Top/到期/超时)面向风控与管理层;销售视图不展示(避免跨人数据)。
|
||||
if (role === '商务/销售') {
|
||||
setExpiring([]); setOverdue([]); setRejectStats([]); setAccuracy({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
|
||||
setExpiring([]); setOverdue([]); setRejectStats([]); setAccuracy({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null }); setCalibration(null);
|
||||
} else {
|
||||
fetch(`${API_BASE}/api/assessments/expiring`).then((r) => r.json()).then(setExpiring).catch(() => setExpiring([]));
|
||||
fetch(`${API_BASE}/api/assessments/overdue`).then((r) => r.json()).then(setOverdue).catch(() => setOverdue([]));
|
||||
fetch(`${API_BASE}/api/reject-reason-stats`).then((r) => r.json()).then(setRejectStats).catch(() => setRejectStats([]));
|
||||
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
|
||||
fetchCalibration().then(setCalibration).catch(() => setCalibration(null));
|
||||
}
|
||||
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
|
||||
if (role === '商务/销售') {
|
||||
@@ -351,12 +357,13 @@ export function Dashboard(): JSX.Element {
|
||||
},
|
||||
};
|
||||
// 销售视图:去掉与「审批进度」重复的列(承接建议/最近处理),插入审批进度,保留操作列。
|
||||
// 风控/管理层视图:去掉冗余的「可接受性」(与承接建议重复),精简列宽避免溢出。
|
||||
const historyColumns = isSales
|
||||
? (() => {
|
||||
const base = columns.filter((col) => col.key !== 'recommendation' && col.key !== 'latest');
|
||||
return [...base.slice(0, -1), approvalProgressCol, base[base.length - 1]!];
|
||||
})()
|
||||
: columns;
|
||||
: columns.filter((col) => col.key !== 'acceptability');
|
||||
const summaryItems = [
|
||||
{ label: isSales ? '我的评估' : '全部评估', value: summary.total, tone: colorVar('color.brand.primary') },
|
||||
{ label: isSales ? '被驳回(待处理)' : '我的待办', value: todoCount, tone: colorVar('color.risk.high') },
|
||||
@@ -471,21 +478,36 @@ export function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
{!consistent && (
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, lineHeight: 1.6 }}>
|
||||
{accuracy.bias === '预测偏乐观' ? '建议下调报价预期或上调成本假设进行校准。' : '成本假设或偏保守,可适当下调目标基准。'}
|
||||
{accuracy.bias === '预测偏乐观' ? '建议上调目标净利率基准以补偿系统性乐观偏差。' : '预测偏保守,可适当下调目标净利率基准。'}
|
||||
{calibration !== null && (
|
||||
<span> 当前基准 <strong>{(calibration.currentBase * 100).toFixed(1)}%</strong>
|
||||
{!calibration.calibrated && <>,建议调整为 <strong style={{ color: accent }}>{(calibration.suggestedBase * 100).toFixed(1)}%</strong></>}。</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{role === '管理层' && !consistent && calibration !== null && (
|
||||
calibration.calibrated ? (
|
||||
<div style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, backgroundColor: 'rgba(16,128,61,0.10)', color: '#15803D', ...typographyStyle('caption'), fontWeight: 600 }}>
|
||||
<Icon name="check-circle" size={14} /> 已按当前偏差校准:目标净利率基准 {(calibration.currentBase * 100).toFixed(1)}%
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCalibration()
|
||||
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%(影响后续承接建议阈值)`); loadAux(); })
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : '校准失败'));
|
||||
}}
|
||||
style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${accent}`, background: 'transparent', color: accent, cursor: 'pointer', fontFamily: FONT_FAMILY, ...typographyStyle('caption'), fontWeight: 600 }}
|
||||
>
|
||||
<Icon name="settings" size={14} /> 应用校准建议({(calibration.currentBase * 100).toFixed(1)}% → {(calibration.suggestedBase * 100).toFixed(1)}%)
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{role === '管理层' && !consistent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCalibration()
|
||||
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%`); loadAux(); })
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : '校准失败'));
|
||||
}}
|
||||
style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${accent}`, background: 'transparent', color: accent, cursor: 'pointer', fontFamily: FONT_FAMILY, ...typographyStyle('caption'), fontWeight: 600 }}
|
||||
>
|
||||
<Icon name="settings" size={14} /> 应用校准建议
|
||||
</button>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, fontSize: '11px', lineHeight: 1.6 }}>
|
||||
注:上方偏差来自已回填项目的历史预测与实际对比,属既成事实,不会因校准而改变;校准仅调整后续评估的目标净利率基准(影响承接建议阈值)。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -584,7 +606,10 @@ export function Dashboard(): JSX.Element {
|
||||
return <span style={{ ...typographyStyle('caption'), fontWeight: 600, color: mine ? '#15803D' : colorVar('color.text.primary') }}>{name}{mine ? '(我)' : ''}</span>;
|
||||
},
|
||||
};
|
||||
const todoColumns = [...columns.slice(0, -1), assignCol, columns[columns.length - 1]!];
|
||||
// 待处理列表精简列(参考销售列表):去掉「可接受性」(与承接建议重复)与「最近处理」
|
||||
//(待审项最近处理即提交动作,对裁决无意义),插入「指派审批人」,保留操作列,避免溢出。
|
||||
const todoBase = columns.filter((col) => col.key !== 'acceptability' && col.key !== 'latest');
|
||||
const todoColumns = [...todoBase.slice(0, -1), assignCol, todoBase[todoBase.length - 1]!];
|
||||
return (
|
||||
<div style={{ marginBottom: `${space(4)}px` }}>
|
||||
<Card title={
|
||||
|
||||
@@ -763,6 +763,31 @@ export function NewAssessment(): JSX.Element {
|
||||
const answeredCount = Object.values(answers).filter((v) => typeof v === 'number').length;
|
||||
const totalIndicators = indicators.length;
|
||||
|
||||
/* --------- 实时报价口径校验:判断当前填写是否足以产生收入(用于第⑤步即时提示) --------- */
|
||||
const revenueWarning: string | null = (() => {
|
||||
const toNum = (s: string): number | undefined => {
|
||||
const t = s.replace(/,/g, '').trim();
|
||||
return t !== '' && Number.isFinite(Number(t)) ? Number(t) : undefined;
|
||||
};
|
||||
const anyUnitPrice = positions.some(
|
||||
(p) => p.name.trim() !== '' && (toNum(p.unitPrice) ?? 0) > 0,
|
||||
);
|
||||
if (pricingModel === 'per_head' || pricingModel === 'volume') {
|
||||
if (!anyUnitPrice && toNum(mgmtFeePerHead) === undefined) {
|
||||
return '尚未填写对客月单价或管理费(元/人/月)。当前报价信息不足,盈利分析收入将为 0。请为岗位填写「对客月单价」,或填写「管理费」。';
|
||||
}
|
||||
} else if (pricingModel === 'cost_plus') {
|
||||
if (toNum(markupRate) === undefined && !anyUnitPrice) {
|
||||
return '尚未填写成本加成率或人月单价。业务/服务外包(成本加成)需填写「成本加成率」(如 0.15)或为岗位填写「人月单价」,否则盈利分析收入将为 0。';
|
||||
}
|
||||
} else if (pricingModel === 'fixed_total') {
|
||||
if ((toNum(contractTotal) ?? 0) <= 0) {
|
||||
return '尚未填写合同总额。固定总价模式请填写「合同总额(含税,元)」,否则盈利分析收入将为 0。';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
/* ----------------------------- 渲染 ----------------------------- */
|
||||
return (
|
||||
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
|
||||
@@ -1031,6 +1056,29 @@ export function NewAssessment(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{revenueWarning !== null && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: `${space(2)}px`,
|
||||
padding: `${space(2)}px ${space(3)}px`,
|
||||
marginBottom: `${space(3)}px`,
|
||||
borderRadius: `${RADIUS.md}px`,
|
||||
border: `1px solid ${colorVar('color.risk.high')}`,
|
||||
backgroundColor: 'rgba(245,158,11,0.10)',
|
||||
color: colorVar('color.text.primary'),
|
||||
...typographyStyle('caption'),
|
||||
}}
|
||||
>
|
||||
<span style={{ color: colorVar('color.risk.high'), flexShrink: 0, marginTop: 1 }}>
|
||||
<Icon name="alert" size={16} />
|
||||
</span>
|
||||
<span><strong>报价信息不足:</strong>{revenueWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary'), marginBottom: `${space(2)}px` }}>
|
||||
岗位明细
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,12 @@ export function SystemLogs(): JSX.Element {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [actions, setActions] = useState<string[]>([]);
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [actors, setActors] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [action, setAction] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
const [method, setMethod] = useState('');
|
||||
const [actorId, setActorId] = useState('');
|
||||
const [success, setSuccess] = useState<'' | 'true' | 'false'>('');
|
||||
const [q, setQ] = useState('');
|
||||
const [qInput, setQInput] = useState('');
|
||||
@@ -43,17 +48,26 @@ export function SystemLogs(): JSX.Element {
|
||||
fetchSystemLogs({
|
||||
page, pageSize,
|
||||
...(action ? { action } : {}),
|
||||
...(role ? { role } : {}),
|
||||
...(method ? { method } : {}),
|
||||
...(actorId ? { actorId } : {}),
|
||||
...(q ? { q } : {}),
|
||||
...(from ? { from: new Date(from).toISOString() } : {}),
|
||||
...(to ? { to: new Date(to).toISOString() } : {}),
|
||||
...(to ? { to: new Date(`${to}T23:59:59`).toISOString() } : {}),
|
||||
...(success ? { success } : {}),
|
||||
})
|
||||
.then((res) => { setItems(res.items); setTotal(res.total); setActions(res.actions); setError(null); })
|
||||
.then((res) => { setItems(res.items); setTotal(res.total); setActions(res.actions); setRoles(res.roles); setActors(res.actors); setError(null); })
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, pageSize, action, q, from, to, success]);
|
||||
}, [page, pageSize, action, role, method, actorId, q, from, to, success]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const hasFilter = action !== '' || role !== '' || method !== '' || actorId !== '' || success !== '' || qInput !== '' || from !== '' || to !== '';
|
||||
const resetFilters = (): void => {
|
||||
setAction(''); setRole(''); setMethod(''); setActorId(''); setSuccess('');
|
||||
setQInput(''); setQ(''); setFrom(''); setTo(''); setPage(1);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const input: React.CSSProperties = {
|
||||
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
|
||||
@@ -72,25 +86,43 @@ export function SystemLogs(): JSX.Element {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: `${space(2)}px` }}>
|
||||
<span>操作日志({total})</span>
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input style={{ ...input, width: 200 }} placeholder="搜索 操作人/动作/路径/目标" value={qInput} onChange={(e) => setQInput(e.target.value)} />
|
||||
<select style={input} value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }}>
|
||||
<option value="">全部动作</option>
|
||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<select style={input} value={success} onChange={(e) => { setSuccess(e.target.value as '' | 'true' | 'false'); setPage(1); }}>
|
||||
<option value="">全部结果</option>
|
||||
<option value="true">成功</option>
|
||||
<option value="false">失败</option>
|
||||
</select>
|
||||
<input style={input} type="date" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} title="起始日期" />
|
||||
<input style={input} type="date" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} title="结束日期" />
|
||||
</div>
|
||||
<Card title={`操作日志(${total})`}>
|
||||
{/* 筛选工具条 */}
|
||||
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center', marginBottom: `${space(3)}px`, paddingBottom: `${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>
|
||||
<input style={{ ...input, width: 220 }} placeholder="搜索 操作人/动作/路径/目标" value={qInput} onChange={(e) => setQInput(e.target.value)} />
|
||||
<select style={input} value={actorId} onChange={(e) => { setActorId(e.target.value); setPage(1); }} title="按操作人筛选">
|
||||
<option value="">全部操作人</option>
|
||||
{actors.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<select style={input} value={role} onChange={(e) => { setRole(e.target.value); setPage(1); }} title="按角色筛选">
|
||||
<option value="">全部角色</option>
|
||||
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<select style={input} value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }} title="按动作筛选">
|
||||
<option value="">全部动作</option>
|
||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<select style={input} value={method} onChange={(e) => { setMethod(e.target.value); setPage(1); }} title="按请求方法筛选">
|
||||
<option value="">全部方法</option>
|
||||
{['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<select style={input} value={success} onChange={(e) => { setSuccess(e.target.value as '' | 'true' | 'false'); setPage(1); }} title="按结果筛选">
|
||||
<option value="">全部结果</option>
|
||||
<option value="true">成功</option>
|
||||
<option value="false">失败</option>
|
||||
</select>
|
||||
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
从 <input style={input} type="date" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} title="起始日期" />
|
||||
</label>
|
||||
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
至 <input style={input} type="date" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} title="结束日期" />
|
||||
</label>
|
||||
{hasFilter && (
|
||||
<button type="button" onClick={resetFilters} style={{ ...pagerBtn, display: 'inline-flex', alignItems: 'center', gap: 4, color: colorVar('color.brand.primary'), borderColor: colorVar('color.brand.primary') }}>
|
||||
<Icon name="close" size={12} /> 清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
{error !== null && <div style={{ color: colorVar('color.risk.critical'), marginBottom: `${space(2)}px` }}>{error}</div>}
|
||||
{loading ? <p style={{ color: colorVar('color.text.secondary') }}>加载中…</p> : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
|
||||
@@ -95,6 +95,14 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
// 先通知后端留痕(登出审计),再清除本地令牌;失败不阻塞登出。
|
||||
try {
|
||||
const token = localStorage.getItem('risk-agent-token');
|
||||
void fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
}).catch(() => undefined);
|
||||
} catch { /* ignore */ }
|
||||
saveToStorage(null);
|
||||
try { localStorage.removeItem('risk-agent-token'); localStorage.removeItem('risk-agent-uid'); } catch { /* ignore */ }
|
||||
set({ isAuthenticated: false, user: null, error: null });
|
||||
|
||||
Reference in New Issue
Block a user