Compare commits

...

10 Commits

Author SHA1 Message Date
freedakgmail cf2991382d chore: 新增演示视频(docs)与录制配置; 移除 AIcoding.md
CI / 类型检查 / 单元测试 / 构建 (push) Waiting to run
CI / 端到端(API 全流程) (push) Blocked by required conditions
- docs/外包项目分控系统介绍.mp4: 多角色带配音演示成品(无字幕)
- demo.config.mjs: demo-video-kit 录制配置
- 删除 AIcoding.md
2026-06-14 22:12:09 +08:00
freedakgmail 49d5e947c2 chore: gitignore 排除 demo-video-kit 与 demo-video-out 2026-06-14 22:11:00 +08:00
freedakgmail eb4c8e8ba3 docs: AIcoding.md 提炼为通用方法论,去除具体项目信息 2026-06-14 11:37:56 +08:00
freedakgmail 87e0a931ba docs: 新增 AIcoding.md — AI辅助编码方法/技巧总结与优化建议 2026-06-14 11:34:54 +08:00
freedakgmail 0fd8489c22 feat(auth): 登出留痕审计
- 原登出为纯前端清 token,不发请求,故无记录
- 新增 POST /api/auth/logout 端点(无状态,仅供审计留痕),deriveActionLabel 加「登出」标签
- 前端 logout 先带 token 通知后端再清本地,失败不阻塞
- 审计中间件经 authMiddleware 解析操作人,记录谁/何时登出
2026-06-14 11:15:00 +08:00
freedakgmail 5ae33502f9 feat(logs): 审计扩展到关键只读操作(导出/查看报告/查看评估详情)
- 写操作(POST/PUT/DELETE)+登录原已全量记录;校准(应用预测校准)亦在内
- 新增 deriveReadActionLabel:对有审计价值的 GET 记录(导出报告/查看报告/查看评估详情),
  跳过看板轮询/列表/聚合等高频只读端点,避免日志噪音
- 修正审计中间件:GET 按只读白名单记录,非关键 GET 不记
2026-06-14 11:08:06 +08:00
freedakgmail 8bac14ef44 fix(calibration): 校准幂等+已校准状态显示,解决重复提示
根因:预测准确度卡的偏差来自历史回填项目的预测vs实际,属既成事实,
不会因校准改变;且原 apply 公式 next=current+dev 会累加,反复点越推越高。

修复:
- 校准建议/应用均基于未校准原始基准(env/默认)计算,保证幂等
- GET /api/calibration 返回 uncalibratedBase 与 calibrated 标志
- 卡片:已校准时显示「已按当前偏差校准:基准X%」绿色状态,不再出现按钮;
  未校准时按钮明示「X% → Y%」
- 补充说明:历史偏差不会因校准改变,校准仅调整后续承接建议阈值
2026-06-14 11:01:55 +08:00
freedakgmail c715dbb306 fix(dashboard): 风控/管理层列表精简列+表格横向滚动,避免溢出
- Table 组件统一加横向滚动容器(overflowX:auto),列多时不撑破卡片
- 参考销售列表精简列:风控/管理层待处理去掉「可接受性」「最近处理」,
  评估历史去掉「可接受性」(与承接建议重复)
- 待处理仍保留指派审批人与操作列
2026-06-14 10:53:44 +08:00
freedakgmail f42c04da8b feat(logs): 日志管理增加筛选维度(操作人/角色/请求方法)
- 后端 querySystemLogs 支持 role/method 过滤;新增 distinctRoles/distinctActors
- 关键词搜索补充 target_name 匹配
- /api/system-logs 返回 roles 与 actors 供前端下拉
- 前端独立筛选工具条:操作人/角色/动作/方法/结果/日期范围 + 清除筛选
- 结束日期改为当日 23:59:59 含当天
2026-06-14 10:27:24 +08:00
freedakgmail 3716564b58 feat(profitability): 第⑤步报价填写时实时提示报价口径不足
- 新增 revenueWarning 实时计算:随岗位单价/加成率/管理费/合同总额变化即时判断
- 报价信息不足时在报价模式区下方显示醒目橙色 alert 横幅,明确指出该填哪个字段
- 与运行前 handleRun 校验形成双重保障(填写时提示+运行时拦截)
2026-06-14 10:20:53 +08:00
11 changed files with 607 additions and 96 deletions
+4
View File
@@ -7,3 +7,7 @@ coverage/
.env
.env.local
deploy.env
# 演示视频工具与产物(不纳入版本库)
demo-video-kit/
demo-video-out/
+294
View File
@@ -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.
+22 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+48 -46
View File
@@ -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
View File
@@ -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={
+48
View File
@@ -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>
+53 -21
View File
@@ -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' }}>
+8
View File
@@ -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 });