diff --git a/frontend/README.md b/frontend/README.md index a5cbdb7..5ab29ee 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,44 @@ # 前端 +## 产品、人员及首页数据体验(2026-11-02) + +产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。 + +本轮 6 项真实浏览器检查覆盖产品提取预览、经理候选、模板入口、首页,以及产品/人员表单五种宽度,错误 0;类型和构建通过。演示业务 `YW20261102-0002` 的样件B留待用户提取;完整操作见 [产品提取与人员岗位操作说明](../docs/产品提取与人员岗位操作说明_2026-11-02.md)。 + +## 当前权限入口(2026-11-02) + +新增角色在同一抽屉填写资料、选择权限并一次保存,无需填写修改原因;后台自动留痕。已有角色继续通过“配置权限”调整并填写原因。权限抽屉支持点击灰色遮罩关闭;未保存时先确认放弃,保存中阻止关闭。全局确认框保留 420px 宽度上限,窄屏留出两侧间距;最新 8 项真实浏览器检查、类型及构建通过,含 2560/1440/768/375/320 宽度。证据 `.ai/role-create-no-reason/result.json`。 + +角色管理配置模块权限,用户管理分配角色并预览只读的继承结果;个人允许/禁止及全局制作人设置入口已取消,旧授权地址跳转角色管理。菜单、路由、按钮与元数据查询按有效权限工作;工资月份锁和经办留痕继续保留。 + +最新类型检查及 Vite 构建通过。独立测试库、独立前后台的真实 Edge 联调 6 项通过,覆盖角色保存与撤权、用户分配工资角色、管理员区域保存、只读提示、768 像素布局和减少动态效果;页面异常及服务端失败为 0。结果 `.ai/authorization-role-real/result.json`;合成响应面板交互另见 `.ai/rbac-browser-result.json`,两类证据分开记录。 + +## 客户页面文案与轻动效(2026-11-02) + +- 客户操作页只呈现业务状态、操作结果和下一步处理提示。开发环境、数据库版本验收、部署进度、实现细节和开发待办保留在内部文档,不作为页面常驻提醒;历史报告中的待确认备注不能冒充当前业务待办。 +- “暂存修改”不等于提交或生效;审核相关文案必须与当前开关及返回结果一致。已生成工资的差额按调整单生成当月处理,原记录保留。 +- 计算依据使用 `components/payroll/LedgerCalculationDetails.vue` 展示中文字段及业务明细,不直接显示原始 JSON。必要的系统日志诊断信息仍按管理员职责展示。 +- 全局弹窗提供正文滚动、独立按钮区和窄屏宽度限制;参数弹窗的类别切换同步取值,错误在字段旁展示,打开后聚焦主要输入控件。 +- 页面切换进入 160ms、离开 80ms,位移 4px;组件反馈 120~180ms。`prefers-reduced-motion: reduce` 下停用页面和组件过渡,不用数字滚动制造金额变化。 +- 本轮前端类型检查、生产构建和相关差异检查通过;浏览器使用合成数据检查参数、T+、账户弹窗、台账依据和分配调整,参数弹窗在 1440、768、390px 宽度下不越界。未执行会写入业务数据的完整 e2e,合成界面检查不替代客户金额验收。 +- 内部审查记录及截图:`.ai/ui-review-20261102/`;参数目录说明与公式相关单元测试结果见 `backend/tests/README.md`。 +- 审查修复:FIFO 跨月续算将历史回款和成本标为“原匹配”,与本期应付差额区分;长弹窗按实际顶部间距和底部留白限制高度。运行 `node frontend/e2e/ledger-dialog-regression.mjs`(项目根目录)可隔离渲染真实组件,验证首次计提、续算、旧记录、加载/空状态,以及 8 种视口下的长弹窗滚动和按钮操作。需已安装的 Playwright 与 Edge,模块路径可通过 `PLAYWRIGHT_MODULE` 指定;仅使用合成数据,结果默认写入 `.ai/ui-review-20261102/regression/`,不连接业务接口。 + +## 角色授权面板(2026-11-02) + +角色管理改为业务分组矩阵:左侧按业务域定位,右侧模块内集中展示操作,支持全选、半选、搜索、复制角色与修改原因。用户管理只分配角色,继承结果只读展示,不再出现个人允许/禁止和全局制作人绑定。旧 `/system/authorization` 跳转角色管理,原“岗位角色”菜单更名为“计提岗位”。 + +`node frontend/e2e/authorization_roles.mjs` 从项目根执行,使用合成响应验证管理员只读、模块矩阵、用户分配、预览失败、版本冲突提示、未保存关闭保护和三档宽度布局;不登录真实账号、不写业务数据。 + +## 系统监控(2026-11-02) + +管理员入口 `/system/monitor`,包括健康总览、接口性能、异常排查和任务与同步。复用现有样式和 ECharts,支持请求编号复制、脱敏诊断摘要及关联 T+ 批次跳转;后台采集不依赖页面打开。 + +`node e2e/monitor.mjs` 从项目根执行时改为 `node frontend/e2e/monitor.mjs`,使用合成响应,不登录真实账号或写业务数据。验证覆盖 1366/1920/768 布局、筛选及详情、下载与任务跳转、连接失败与空数据、关闭刷新、页面隐藏/恢复、离开页面停止轮询、普通用户拒绝访问,共 9 组检查,页面脚本错误 0。需要已安装的 Playwright,可用 `PLAYWRIGHT_MODULE` 指定模块路径;默认前端为 `http://127.0.0.1:5193`,可通过 `MONITOR_UI_URL` 指定。 + +本轮前端 `vue-tsc --noEmit` 与 Vite 生产构建通过。采集范围、指标近似口径及故障边界见 [系统监控运维说明](../docs/系统监控运维说明.md)。 + > AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。已实现登录链路(2026-10 视觉方向重做:点阵底/涟漪/天光/居中卡片)、系统管理页面、岗位角色与计提参数配置页、Bento 仪表盘首页;视觉体系与设计预览见 `docs/design/视觉方向预览.html`。 ## 职责 diff --git a/frontend/e2e/authorization_roles.mjs b/frontend/e2e/authorization_roles.mjs new file mode 100644 index 0000000..51516c0 --- /dev/null +++ b/frontend/e2e/authorization_roles.mjs @@ -0,0 +1,126 @@ +/** 角色授权界面验收;合成数据只验证交互,不改真实账号或权限。 */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const base = process.env.AUTH_UI_URL || 'http://127.0.0.1:5173' +const output = process.env.AUTH_UI_OUTPUT || '.ai' +const browser = await chromium.launch({ headless: true, channel: 'msedge' }) +const page = await browser.newPage({ viewport: { width: 1366, height: 1000 } }) +const checks = [], errors = [], saves = [] +let revision = 1, failPreview = false, conflict = false +const names = { view: '查看', create: '新增', update: '编辑', regions: '维护区域', plans: '维护计划', prepare: '准备核算资料', calculate: '核算与重算', allocation: '修改工资分配', generate: '生成工资', export: '导出' } +const definitions = [ + ['system:users', '系统管理', '用户管理', ['view', 'create', 'update']], + ['system:roles', '系统管理', '角色管理', ['view', 'create', 'update']], + ['base:personnel', '基础资料', '人员管理', ['view', 'create', 'update', 'regions', 'plans']], + ['payroll', '绩效工资', '正式核算与分配', ['view', 'prepare', 'calculate', 'allocation']], + ['settlements', '绩效工资', '月度工资', ['view', 'generate']], + ['reports:business', '报表统计', '业务统计', ['view', 'export']], +] +const catalog = definitions.flatMap(([module, group, module_name, actions]) => actions.map(action => ({ code: `${module}:${action}`, module, group, module_name, name: names[action], action, view_code: `${module}:view`, admin_only: module.startsWith('system:'), maker_required: false }))) +const roleCodes = { admin: catalog.map(item => item.code), finance: ['payroll:view'], payroll_maker: ['payroll:view', 'payroll:calculate', 'settlements:view', 'settlements:generate'] } +const roles = [{ id: 1, role_code: 'admin', role_name: '系统管理员', description: '系统管理', status: 1, user_count: 1 }, { id: 2, role_code: 'finance', role_name: '财务', status: 1, user_count: 1 }, { id: 3, role_code: 'payroll_maker', role_name: '工资经办', status: 1, user_count: 0 }] +let assigned = ['finance'] +function effective(codes) { return [...new Set(codes.flatMap(code => roleCodes[code] ?? []))] } +function result(codes) { const permissions = effective(codes); return { permissions, roles: codes, sources: Object.fromEntries(catalog.map(item => [item.code, { roles: codes.filter(code => roleCodes[code]?.includes(item.code)), override: null, effective: permissions.includes(item.code) }])) } } +await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-rbac-ui')) +page.on('pageerror', error => errors.push(error.message)) +await page.route(url => url.pathname.startsWith('/api/'), async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname, method = request.method() + let data = {}, status = 200 + if (path.endsWith('/auth/me')) data = { id: 1, username: 'synthetic-admin', real_name: '授权界面演练', roles: ['admin'], permissions: roleCodes.admin, can_write_payroll: true, is_payroll_maker: true, maker_required_permissions: [] } + else if (path.endsWith('/permissions/catalog')) data = catalog + else if (path.endsWith('/system/roles')) data = { list: roles.map(item => ({ ...item, permission_count: roleCodes[item.role_code].length })), total: roles.length, pageNum: 1, pageSize: 200 } + else if (path.endsWith('/users/meta/roles')) data = roles + else if (/\/roles\/\d+\/permissions$/.test(path)) { + const id = Number(path.match(/roles\/(\d+)/)[1]), item = roles.find(role => role.id === id) + if (method === 'PUT') { + const body = request.postDataJSON(); saves.push({ path, body }) + if (conflict || body.expected_revision !== revision) status = 409 + else { roleCodes[item.role_code] = body.permission_codes; revision++ } + } + data = { role_id: id, role_code: item.role_code, role_name: item.role_name, permission_codes: roleCodes[item.role_code], fixed: id === 1, revision } + } else if (path.endsWith('/system/users')) data = { list: [{ id: 7, username: 'synthetic-user', real_name: '角色继承演练', roles: assigned, status: 1 }], total: 1, pageNum: 1, pageSize: 20 } + else if (path.endsWith('/users/7/authorization/preview')) { + if (failPreview) status = 503 + data = result(request.postDataJSON().role_codes) + } else if (path.endsWith('/users/7/authorization')) { + if (method === 'PUT') { const body = request.postDataJSON(); saves.push({ path, body }); assigned = body.role_codes; revision++ } + data = { ...result(assigned), id: 7, username: 'synthetic-user', role_codes: assigned, overrides: [], revision, is_payroll_maker: true } + } + return route.fulfill({ status, json: { code: status, message: status === 409 ? '权限版本已更新' : status === 503 ? '合成预览失败' : 'success', data: status === 200 ? data : null } }) +}) + +try { + await page.goto(`${base}/system/roles`) + await page.getByRole('button', { name: '查看权限', exact: true }).click() + await page.getByText('系统管理员拥有全部权限,仅供查看', { exact: true }).waitFor() + assert.equal(await page.getByRole('button', { name: '保存角色权限', exact: true }).count(), 0) + assert.equal(await page.locator('.permission-drawer .el-table').count(), 0) + for (const width of [1366, 1920, 768]) { + await page.setViewportSize({ width, height: 1000 }) + await page.screenshot({ path: `${output}/rbac-admin-${width}.png`, fullPage: true }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + } + checks.push('管理员固定只读、模块分组、1366/1920/768 布局无横向溢出') + await page.getByRole('button', { name: '关闭', exact: true }).click() + await page.setViewportSize({ width: 1366, height: 1000 }) + await page.getByRole('row').filter({ hasText: '财务' }).getByRole('button', { name: '配置权限', exact: true }).click() + await page.getByRole('navigation', { name: '权限业务分组' }).getByRole('button', { name: /^绩效工资/ }).click() + await page.getByText('核算与重算', { exact: true }).click() + assert.ok(await page.getByRole('checkbox', { name: '正式核算与分配 查看', exact: true }).isChecked()) + assert.ok(await page.getByRole('checkbox', { name: '正式核算与分配 核算与重算', exact: true }).isChecked()) + const moduleAll = page.getByRole('checkbox', { name: '正式核算与分配模块全选', exact: true }) + assert.equal(await moduleAll.evaluate(element => element.indeterminate), true) + await page.getByLabel('正式核算与分配权限', { exact: true }).getByText('全选', { exact: true }).click() + assert.ok(await page.getByRole('checkbox', { name: '正式核算与分配 修改工资分配', exact: true }).isChecked()) + await page.getByRole('textbox', { name: '搜索权限', exact: true }).fill('人员') + await page.getByRole('checkbox', { name: '人员管理模块全选', exact: true }).locator('xpath=ancestor::label[1]').click() + await page.getByRole('textbox', { name: '搜索权限', exact: true }).fill('不存在的模块') + await page.getByText('没有匹配的权限', { exact: true }).waitFor() + await page.getByRole('textbox', { name: '搜索权限', exact: true }).fill('') + await page.getByRole('textbox', { name: '授权修改原因', exact: true }).fill('合成验收:财务经办职责调整') + await page.screenshot({ path: `${output}/rbac-role-editor.png`, fullPage: true }) + await page.getByRole('button', { name: '保存角色权限', exact: true }).click() + await page.getByRole('dialog').waitFor({ state: 'hidden' }) + assert.ok(saves[0].body.permission_codes.includes('payroll:view') && saves[0].body.permission_codes.includes('payroll:calculate')) + checks.push('操作联动查看、模块全选与半选、搜索空态、原因必填及保存实际载荷') + await page.goto(`${base}/system/users`) + await page.getByRole('button', { name: '分配角色', exact: true }).click() + await page.getByRole('heading', { name: '分配角色', exact: true }).waitFor() + assert.equal(await page.getByText('个人设置', { exact: true }).count(), 0) + assert.equal(await page.getByText('额外允许', { exact: true }).count(), 0) + await page.getByRole('checkbox', { name: '分配工资经办角色', exact: true }).locator('xpath=ancestor::label[1]').click() + await page.getByText('已选:财务、工资经办', { exact: false }).waitFor() + await page.getByRole('textbox', { name: '授权修改原因', exact: true }).fill('合成验收:增加工资经办角色') + await page.screenshot({ path: `${output}/rbac-user-role-assignment.png`, fullPage: true }) + await page.getByRole('button', { name: '保存角色分配', exact: true }).click() + await page.getByRole('dialog').waitFor({ state: 'hidden' }) + assert.deepEqual(saves.at(-1).body.role_codes, ['finance', 'payroll_maker']) + assert.deepEqual(saves.at(-1).body.overrides, []) + checks.push('用户只分配角色、继承权限只读预览、无个人权限覆盖入口') + await page.getByRole('button', { name: '分配角色', exact: true }).click() + failPreview = true + await page.getByRole('checkbox', { name: '分配工资经办角色', exact: true }).locator('xpath=ancestor::label[1]').click() + await page.getByText('继承权限预览失败,请重试后再保存。', { exact: true }).waitFor() + assert.ok(await page.getByRole('button', { name: '保存角色分配', exact: true }).isDisabled()) + failPreview = false + await page.getByRole('button', { name: '重试预览', exact: true }).click() + await page.getByRole('textbox', { name: '授权修改原因', exact: true }).fill('角色变更演练') + await page.getByRole('button', { name: '取消', exact: true }).click() + await page.getByRole('button', { name: '放弃修改', exact: true }).click() + checks.push('预览失败禁止保存、重试恢复及未保存修改关闭保护') + await page.goto(`${base}/system/authorization`) + await page.waitForURL('**/system/roles') + assert.equal(await page.getByText('权限与工资制作人', { exact: true }).count(), 0) + checks.push('旧授权页跳转角色管理,导航无重复入口') + assert.deepEqual(errors, []) + fs.writeFileSync(`${output}/rbac-browser-result.json`, JSON.stringify({ checks, errors, saves: saves.length }, null, 2)) + console.log(JSON.stringify({ checks, errors, saves: saves.length })) +} catch (error) { + await page.screenshot({ path: `${output}/rbac-browser-failure.png`, fullPage: true }) + fs.writeFileSync(`${output}/rbac-browser-failure.txt`, String(error) + '\n' + await page.locator('body').innerText()) + throw error +} finally { await browser.close() } diff --git a/frontend/e2e/calc_settlement_flow.py b/frontend/e2e/calc_settlement_flow.py index 79be522..651723d 100644 --- a/frontend/e2e/calc_settlement_flow.py +++ b/frontend/e2e/calc_settlement_flow.py @@ -259,8 +259,8 @@ def step_version_filter(page: Page, ctx: dict) -> None: def step_generate_settlement(page: Page, ctx: dict) -> None: page.goto(f"{BASE_URL}/settlements", wait_until="networkidle") page.wait_for_timeout(600) - page.locator('input[placeholder="结算月份 YYYY-MM"]').fill(ctx["month"]) - page.click("button:has-text('生成月结快照')") + page.locator('input[placeholder="选择结算月份"]').fill(ctx["month"]) + page.click("button:has-text('生成月度工资')") expect_toast(page, "月结已生成", "生成月结") row = page.locator(".el-table__row", has_text=ctx["month"]).first diff --git a/frontend/e2e/monitor.mjs b/frontend/e2e/monitor.mjs new file mode 100644 index 0000000..941d57f --- /dev/null +++ b/frontend/e2e/monitor.mjs @@ -0,0 +1,125 @@ +/** 合成数据验证监控交互和故障状态,不登录真实账号、不写业务数据。 */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const base = process.env.MONITOR_UI_URL || 'http://127.0.0.1:5193' +const output = process.env.MONITOR_UI_OUTPUT || '.ai' +fs.mkdirSync(output, { recursive: true }) +const browser = await chromium.launch({ headless: true, channel: 'msedge' }) +const context = await browser.newContext({ viewport: { width: 1366, height: 1050 }, permissions: ['clipboard-read', 'clipboard-write'] }) +const page = await context.newPage() +const errors = [], requests = [], checks = [] +let failed = false, role = 'admin', empty = false +const at = '2026-11-02T10:00:00', id = 'a'.repeat(32) +const metric = { count: 48, errors_5xx: 3, errors_4xx: 2, error_rate: 6.25, p95_ms: 1200, max_ms: 2600, last_error_at: at } +const incident = { id: 1, instance_id: 'synthetic-instance', kind: 'HEALTH', code: 'WORKER_OFFLINE', component: 'tplus', level: 'warning', message: '采集进程心跳中断,请检查托管任务', request_id: null, method: null, route: null, status_code: null, duration_ms: null, batch_no: 'SYNTHETIC-BATCH', first_seen: at, last_seen: at, occurrences: 4, resolved_at: null } +const event = { ...incident, id: 2, kind: 'REQUEST_ERROR', code: 'HTTP_5XX', component: 'http', level: 'error', message: '接口处理异常,请按请求编号检索服务端日志。', request_id: id, method: 'GET', route: '/api/v1/base/products/{id}', status_code: 500, duration_ms: 2600, occurrences: 1, batch_no: null } +page.on('pageerror', error => errors.push(error.message)) +await page.clock.install({ time: new Date('2026-11-02T10:00:00+08:00') }) +await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-monitor-browser-test')) +await page.route(url => url.pathname.startsWith('/api/'), async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname + requests.push({ path, query: Object.fromEntries(url.searchParams) }) + assert.equal(request.method(), 'GET') + if (path.endsWith('/auth/me')) return route.fulfill({ json: { code: 200, message: 'success', data: { id: 1, username: 'synthetic', real_name: '监控页面验证', roles: [role], permissions: role === 'admin' ? ['system:monitor:view', 'tplus:view', 'dashboard:view'] : ['base:products:view'], is_payroll_maker: false, can_write_payroll: false, payroll_approval_enabled: false } } }) + if (path.includes('/monitor/') && failed) return route.fulfill({ status: 503, json: { code: 503, message: '合成连接故障', data: null } }) + let data = {} + if (path.endsWith('/monitor/overview')) data = { + status: empty ? 'unknown' : 'warning', environment: '合成演练', version: '0.1.0', instance_id: 'synthetic-instance', uptime_seconds: 12345, + collected_at: empty ? null : at, last_persisted_at: empty ? null : at, collection_status: empty ? 'unknown' : 'normal', collection_message: empty ? '等待首次采集' : '采集正常', dropped_events: 0, + resources: empty ? {} : { cpu_percent: 32.8, memory_percent: 48.2, disk_percent: 55.4, disk_free_gb: 124.5, process_cpu_percent: 2.3, process_memory_mb: 185, scope: '合成数据,仅用于页面验证' }, + requests: empty ? { count: 0, error_rate: 0, p95_ms: null, max_ms: 0, errors_4xx: 0, errors_5xx: 0, last_error_at: null } : metric, + dependencies: empty ? [] : ['应用', 'MySQL', 'Redis', 'T+ 采集进程'].map(name => ({ name, status: name.startsWith('T+') ? 'warning' : 'normal', message: name.startsWith('T+') ? '心跳中断,请查看同步任务。' : '连接正常。', checked_at: at, duration_ms: 2 })), + alerts: empty ? [] : [incident], tasks: { status: 'warning', message: '存在采集异常,请查看相关任务。', enabled: true, worker_phase: 'STOPPED', heartbeat_at: at, last_success_at: at, next_retry_at: at, alerts: [{ code: 'WORKER_OFFLINE', message: incident.message, batch_no: incident.batch_no }] }, + } + else if (path.endsWith('/monitor/trends')) data = { step_minutes: 1, points: empty ? [] : Array.from({ length: 60 }, (_, i) => ({ at: `2026-11-02T09:${String(i).padStart(2, '0')}:00`, sample_count: i === 25 ? 0 : 4, cpu_percent: i === 25 ? null : 25 + i % 13, memory_percent: i === 25 ? null : 42 + i % 8, disk_percent: 55, request_count: i === 25 ? null : i % 9 + 1, error_rate: i === 25 ? null : i % 17 === 0 ? 5 : 0, p95_ms: i === 25 ? null : 100 + i % 7 * 100 })) } + else if (path.endsWith('/monitor/interfaces')) data = { list: empty ? [] : [{ method: event.method, route: event.route, ...metric }], truncated: false } + else if (path.endsWith('/monitor/events')) data = { list: empty ? [] : [event], total: empty ? 0 : 1, pageNum: 1, pageSize: 20 } + else if (/\/monitor\/events\/\d+$/.test(path)) data = path.endsWith('/1') ? incident : event + else if (path.endsWith('/monitor/diagnostic')) return route.fulfill({ headers: { 'content-disposition': 'attachment; filename="monitor.json"' }, json: { generated_at: at, note: '脱敏诊断摘要' } }) + else if (path.endsWith('/tplus/overview')) data = { is_configured: false, configuration_message: '合成任务定位验证', status_counts: {}, total_staged_records: 0, latest_audit: null } + else if (path.endsWith('/tplus/batches/SYNTHETIC-BATCH')) data = { batch_no: 'SYNTHETIC-BATCH', status: 'FAILED', task_type: 'READ', create_time: at, can_retry: false } + else data = { list: [], total: 0, pageNum: 1, pageSize: 20 } + return route.fulfill({ json: { code: 200, message: 'success', data } }) +}) + +try { + await page.goto(`${base}/system/monitor`) + await page.getByRole('heading', { name: '系统监控', exact: true }).waitFor() + await page.getByText('合成演练', { exact: true }).waitFor() + await page.getByText(incident.message, { exact: true }).waitFor() + await page.clock.runFor(600) + for (const width of [1366, 1920, 768]) { + await page.setViewportSize({ width, height: 1050 }) + await page.screenshot({ path: `${output}/monitor-ui-${width}.png`, fullPage: true }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `页面横向溢出 ${width}`) + } + checks.push('1366/1920/768 布局、真实图表渲染与无横向溢出') + await page.setViewportSize({ width: 1366, height: 1050 }) + await page.getByRole('tab', { name: '接口性能', exact: true }).click() + await page.getByRole('button', { name: '查看异常', exact: true }).click() + await page.getByRole('button', { name: '详情', exact: true }).click() + await page.getByRole('heading', { name: '诊断事件详情', exact: true }).waitFor() + await page.getByRole('button', { name: '复制请求编号', exact: true }).click() + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), id) + await page.screenshot({ path: `${output}/monitor-event-detail.png`, fullPage: true }) + await page.getByRole('button', { name: '关闭此对话框' }).click() + checks.push('接口排行联动异常列表、抽屉详情与请求编号复制') + await page.getByRole('button', { name: '诊断摘要', exact: true }).click() + await page.getByRole('tab', { name: '任务与同步', exact: true }).click() + await page.getByRole('button', { name: '查看相关批次', exact: true }).click() + await page.waitForURL('**/system/tplus?batch=SYNTHETIC-BATCH') + await page.getByRole('dialog').waitFor() + checks.push('诊断摘要下载、T+ 批次跳转与原详情打开') + await page.goto(`${base}/system/monitor`) + await page.getByText('合成演练', { exact: true }).waitFor() + await Promise.all([page.waitForResponse(response => response.url().includes('/monitor/trends') && response.url().includes('hours=24')), + page.getByText('24 小时', { exact: true }).click()]) + checks.push('历史时间窗口筛选') + failed = true + await page.getByRole('button', { name: '刷新', exact: true }).click() + await page.getByText('监控连接失败,请检查服务或稍后刷新。', { exact: true }).waitFor() + assert.equal(await page.locator('.el-message--error').count(), 0, '轮询故障不应重复弹窗') + assert.ok(await page.getByText('合成演练', { exact: true }).isVisible(), '应保留上次成功状态') + checks.push('故障保留上次状态、明确未知且不弹窗轰炸') + failed = false; empty = true + await page.getByRole('button', { name: '刷新', exact: true }).click() + await page.getByText('当前状态尚不能确认', { exact: true }).waitFor() + assert.equal(await page.getByText('当前未发现活动异常', { exact: true }).count(), 0) + checks.push('初次无数据与正常状态明确区分') + const count = () => requests.filter(item => item.path.endsWith('/monitor/overview')).length + await page.locator('.toolbar .el-switch').click() + assert.equal(await page.getByRole('switch', { name: '自动刷新监控' }).isChecked(), false) + const disabledCount = count() + await page.clock.runFor(31000) + assert.equal(count(), disabledCount) + await page.locator('.toolbar .el-switch').click() + await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); document.dispatchEvent(new Event('visibilitychange')) }) + const hiddenCount = count() + await page.clock.runFor(46000) + assert.equal(count(), hiddenCount) + await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); document.dispatchEvent(new Event('visibilitychange')) }) + const resumed = page.waitForRequest(request => request.url().includes('/monitor/overview')) + await page.clock.runFor(16000); await resumed + checks.push('关闭自动刷新、隐藏页面暂停、恢复可见后继续采集视图') + await page.getByRole('menuitem', { name: 'T+ 数据同步', exact: true }).click() + const leftCount = count() + await page.clock.runFor(46000) + assert.equal(count(), leftCount) + checks.push('离开监控页停止轮询') + role = 'user' + await page.goto(`${base}/system/monitor`) + await page.waitForURL('**/base/products') + assert.equal(await page.getByRole('heading', { name: '系统监控', exact: true }).count(), 0) + checks.push('普通用户直接访问被拒绝,菜单不暴露监控入口') + assert.deepEqual(errors, []) + fs.writeFileSync(`${output}/monitor-browser-result.json`, JSON.stringify({ checks, errors, requests: requests.length }, null, 2)) + console.log(JSON.stringify({ checks, errors, requests: requests.length })) +} catch (error) { + await page.screenshot({ path: `${output}/monitor-browser-failure.png`, fullPage: true }) + fs.writeFileSync(`${output}/monitor-browser-failure.txt`, String(error) + '\n' + JSON.stringify({ errors, requests, url: page.url() }) + '\n' + await page.locator('body').innerText()) + throw error +} finally { await browser.close() } diff --git a/frontend/e2e/param_management.py b/frontend/e2e/param_management.py index 2da1ee5..1c53183 100644 --- a/frontend/e2e/param_management.py +++ b/frontend/e2e/param_management.py @@ -58,10 +58,10 @@ def main() -> int: step("2. 修改预付款比例 10 → 12(立即生效)") row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first - row.get_by_role("button", name="改", exact=True).click() + row.get_by_role("button", name="修改", exact=True).click() dialog = page.locator(".el-dialog") dialog.locator("input").fill("12") - dialog.get_by_role("button", name="加入待提交").click() + dialog.get_by_role("button", name="暂存修改").click() if "待改为 12" not in page.content(): fail("待提交", "参数行未出现「待改为 12」标记") @@ -87,10 +87,10 @@ def main() -> int: step("6. 恢复种子值 10") row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first - row.get_by_role("button", name="改", exact=True).click() + row.get_by_role("button", name="修改", exact=True).click() dialog = page.locator(".el-dialog") dialog.locator("input").fill("10") - dialog.get_by_role("button", name="加入待提交").click() + dialog.get_by_role("button", name="暂存修改").click() page.get_by_role("button", name="提交变更").click() page.locator(".el-message-box").get_by_role("button", name="提交").click() wait_toast(page, "已提交") @@ -111,7 +111,7 @@ def main() -> int: drawer.locator(".el-form-item", has_text="销售额").locator("input").fill("100000") drawer.locator(".el-form-item", has_text="毛利润额").locator("input").fill("45000") drawer.locator(".el-form-item", has_text="本笔回款").locator("input").fill("60000") - drawer.get_by_role("button", name="试 算").click() + drawer.get_by_role("button", name="开始试算").click() page.wait_for_timeout(900) result_text = drawer.locator(".preview-result").text_content() for expected in ["27 天", "145.5", "97/10000"]: diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 7c75c20..c0da0c2 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -22,6 +22,10 @@ export interface UserInfo { roles: string[] can_write_payroll?: boolean payroll_approval_enabled?: boolean + permissions?: string[] + authorization_revision?: number + is_payroll_maker?: boolean + maker_required_permissions?: string[] last_login_time?: string | null } diff --git a/frontend/src/api/authorization.ts b/frontend/src/api/authorization.ts new file mode 100644 index 0000000..67b359e --- /dev/null +++ b/frontend/src/api/authorization.ts @@ -0,0 +1,23 @@ +import { request } from './request' + +export interface PermissionItem { + code: string; module: string; group: string; module_name: string; name: string + action: string; view_code: string; admin_only: boolean; maker_required: boolean +} +export interface Override { permission_code: string; effect: 'ALLOW' | 'DENY' } +export interface AuthorizationResult { + permissions: string[]; roles: string[] + sources: Record + added?: string[]; removed?: string[] +} +export interface UserAuthorization extends AuthorizationResult { + id: number; username: string; role_codes: string[]; overrides: Override[]; revision: number; is_payroll_maker: boolean +} +export interface RolePermissions { role_id: number; role_code: string; role_name: string; permission_codes: string[]; revision: number; fixed: boolean } +const base = '/v1/system' +export const catalog = (): Promise => request({url: `${base}/permissions/catalog`}) +export const getUser = (id: number): Promise => request({url: `${base}/users/${id}/authorization`}) +export const previewUser = (id: number, data: {role_codes: string[]; overrides: Override[]}): Promise => request({url: `${base}/users/${id}/authorization/preview`, method: 'post', data}) +export const saveUser = (id: number, data: {role_codes: string[]; overrides: Override[]; expected_revision: number; reason: string}): Promise => request({url: `${base}/users/${id}/authorization`, method: 'put', data}) +export const getRole = (id: number): Promise => request({url: `${base}/roles/${id}/permissions`}) +export const saveRole = (id: number, data: {permission_codes: string[]; expected_revision: number; reason: string}): Promise => request({url: `${base}/roles/${id}/permissions`, method: 'put', data}) diff --git a/frontend/src/api/businessRoles.ts b/frontend/src/api/businessRoles.ts index 186327d..1741fa7 100644 --- a/frontend/src/api/businessRoles.ts +++ b/frontend/src/api/businessRoles.ts @@ -101,3 +101,70 @@ export function setBusinessRoleStatus( export function listTemplateOptions(): Promise { return request({ url: '/v1/system/business-roles/meta/templates', method: 'get' }) } + +/** 公式模板管理项(对应后端 `CalcTemplateOut`) */ +export interface CalcTemplateItem { + id: number + template_code: string + template_name: string + formula_type: string + /** 骨架参数角色 → 参数编码 */ + params: Record + description: string | null + sort_order: number + status: number + is_system: number + role_count: number +} + +/** 公式骨架下拉项(受控集合) */ +export interface FormulaOption { + code: string + name: string + description: string + roles: string[] +} + +export interface CalcTemplateCreateParams { + template_code: string + template_name: string + formula_type: string + params: Record + description?: string + sort_order?: number +} + +export interface CalcTemplateUpdateParams { + template_name: string + params: Record + description?: string + sort_order?: number +} + +/** 公式模板列表(含停用与岗位引用计数) */ +export function listCalcTemplates(): Promise { + return request({ url: '/v1/system/calc-templates', method: 'get' }) +} + +/** 公式骨架下拉(新增模板时选择) */ +export function listFormulaOptions(): Promise { + return request({ url: '/v1/system/calc-templates/meta/formulas', method: 'get' }) +} + +/** 新增公式模板 */ +export function createCalcTemplate(data: CalcTemplateCreateParams): Promise { + return request({ url: '/v1/system/calc-templates', method: 'post', data }) +} + +/** 编辑公式模板(编码与骨架锁定) */ +export function updateCalcTemplate( + templateId: number, + data: CalcTemplateUpdateParams, +): Promise { + return request({ url: `/v1/system/calc-templates/${templateId}`, method: 'put', data }) +} + +/** 删除公式模板(系统模板与被岗位引用的模板后端拒绝) */ +export function deleteCalcTemplate(templateId: number): Promise { + return request({ url: `/v1/system/calc-templates/${templateId}`, method: 'delete' }) +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 5ff42cb..49437c1 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -92,6 +92,14 @@ export interface DashboardData { overview: OverviewBlock todos: TodoBlock warnings: WarningBlock + sync: { + available: boolean + enabled: boolean + automation_enabled: boolean + latest_status: string | null + last_success_at: string | null + historical_failure_total: number + } } /** diff --git a/frontend/src/api/metadata.ts b/frontend/src/api/metadata.ts new file mode 100644 index 0000000..73d8985 --- /dev/null +++ b/frontend/src/api/metadata.ts @@ -0,0 +1,11 @@ +import { request } from './request' +import type { PageData } from './types' + +export function options(resource: string, params: {business_id?: number; search?: string} = {}): Promise { + return request({url: '/v1/meta/options', params: {resource, ...params}}) +} + +export async function optionPage(resource: string, params: {business_id?: number; search?: string} = {}): Promise> { + const list = await options(resource, params) + return {list, total: list.length, pageNum: 1, pageSize: 200} +} diff --git a/frontend/src/api/monitor.ts b/frontend/src/api/monitor.ts new file mode 100644 index 0000000..3980111 --- /dev/null +++ b/frontend/src/api/monitor.ts @@ -0,0 +1,62 @@ +import service, { request } from './request' +import type { PageData } from './types' + +export type MonitorStatus = 'normal' | 'warning' | 'error' | 'unknown' +export interface Metric { + count: number; errors_5xx: number; errors_4xx: number + error_rate: number; p95_ms: number | null; max_ms: number + last_error_at: string | null +} +export interface MonitorEvent { + id: number; instance_id: string; kind: string; code: string; component: string + level: string; message: string; request_id: string | null; method: string | null + route: string | null; status_code: number | null; duration_ms: number | null + batch_no: string | null; first_seen: string; last_seen: string + occurrences: number; resolved_at: string | null +} +export interface Dependency { + name: string; status: MonitorStatus; message: string; checked_at: string | null + duration_ms: number | null +} +export interface MonitorOverview { + status: MonitorStatus; environment: string; version: string; instance_id: string + uptime_seconds: number; collected_at: string | null; last_persisted_at: string | null + collection_status: MonitorStatus; collection_message: string; dropped_events: number + resources: { cpu_percent?: number | null; memory_percent?: number | null + disk_percent?: number | null; process_cpu_percent?: number | null + process_memory_mb?: number | null; disk_free_gb?: number; scope?: string; process_cpu_scope?: string } + requests: Metric; dependencies: Dependency[]; alerts: MonitorEvent[] + tasks: { status?: MonitorStatus; message?: string; enabled?: boolean; worker_phase?: string + heartbeat_at?: string | null; last_success_at?: string | null; next_retry_at?: string | null + stop_requested?: boolean; alerts?: { code: string; message: string; batch_no?: string }[] } +} +export interface MonitorWindow { hours: 1 | 24 | 168; end?: string; route?: string } +export interface EventQuery extends MonitorWindow { + kind?: string; request_id?: string; active_only?: boolean; pageNum: number; pageSize: number +} +export interface TrendPoint { + at: string; sample_count: number; cpu_percent: number | null; memory_percent: number | null + disk_percent: number | null; request_count: number | null; error_rate: number | null; p95_ms: number | null +} +export interface Trends { points: TrendPoint[]; step_minutes: number } +export interface InterfaceMetric extends Metric { method: string; route: string } +const base = '/v1/system/monitor' +export function getOverview(signal?: AbortSignal): Promise { + return request({ url: `${base}/overview`, silentError: true, signal }) +} +export function getTrends(params: MonitorWindow, signal?: AbortSignal): Promise { + return request({ url: `${base}/trends`, params, silentError: true, signal }) +} +export function getInterfaces(params: MonitorWindow, signal?: AbortSignal): Promise<{ list: InterfaceMetric[]; truncated: boolean }> { + return request({ url: `${base}/interfaces`, params, silentError: true, signal }) +} +export function getEvents(params: EventQuery, signal?: AbortSignal): Promise> { + return request({ url: `${base}/events`, params, silentError: true, signal }) +} +export function getEvent(id: number, signal?: AbortSignal): Promise { + return request({ url: `${base}/events/${id}`, silentError: true, signal }) +} +export async function downloadDiagnostic(params: EventQuery): Promise { + const result = await service.get(`${base}/diagnostic`, { params, responseType: 'blob', silentError: true }) + return result.data +} diff --git a/frontend/src/api/personnel.ts b/frontend/src/api/personnel.ts index 00b821d..e2000d9 100644 --- a/frontend/src/api/personnel.ts +++ b/frontend/src/api/personnel.ts @@ -27,6 +27,9 @@ export interface PersonnelItem { manager_name: string | null phone: string | null /** 入职日期,ISO 串(YYYY-MM-DD) */ + probation_start_date?: string | null + regularization_date?: string | null + resignation_date?: string | null hire_date: string | null /** 1试用期 0正式 */ is_probation: number @@ -39,6 +42,7 @@ export interface PersonnelItem { /** 人员详情:附辐射区域与拓展计划,供编辑抽屉一次回显 */ export interface PersonnelDetail extends PersonnelItem { + salary_history_json?: Record[] | null regions: PersonnelRegionItem[] plans: PersonnelPlanItem[] } @@ -61,12 +65,16 @@ export interface PersonnelQueryParams { * 误触离职。可空字段传 `null` 表示清空,与后端 `PersonnelCreateIn` 对齐。 */ export interface PersonnelSaveParams { + salary_effective_date?: string | null personnel_name: string business_role_id: number /** 直属经理ID,空=无;后端校验不能是自己且须在职 */ manager_id?: number | null phone?: string | null /** ISO 串(YYYY-MM-DD) */ + probation_start_date?: string | null + regularization_date?: string | null + resignation_date?: string | null hire_date?: string | null /** 1试用期 0正式 */ is_probation?: number @@ -117,11 +125,11 @@ export function deletePersonnel(personnelId: number): Promise { } /** 设为在职 / 离职(离职人员不可再被业务与客户归属引用) */ -export function setPersonnelStatus(personnelId: number, status: number): Promise { +export function setPersonnelStatus(personnelId: number, status: number, effective_date?: string): Promise { return request({ url: `/v1/base/personnel/${personnelId}/status`, method: 'put', - data: { status }, + data: { status, effective_date }, }) } diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts index 8d14290..e3e2188 100644 --- a/frontend/src/api/products.ts +++ b/frontend/src/api/products.ts @@ -8,6 +8,8 @@ import type { PageData } from './types' * 展示原串、提交原串,Decimal 转换收敛在后端 service 层。 */ export interface ProductItem { + source_trade_json?: {quantity:string;price:string;date:string;source_no:string} + quantity_price_manual?: number id: number product_name: string /** 规格型号,可空串(与名称构成组合唯一键) */ @@ -89,3 +91,5 @@ export function setProductStatus(productId: number, status: number): Promise {return request({url:`/v1/base/products/${id}/restore-source`,method:'post'})} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index a9f7fe2..7a8be3a 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -106,6 +106,7 @@ export interface DepartmentReconciliation { } export interface DepartmentSummary { + salary_rows?: {personnel_id:number;personnel_name:string;basic_amount:string|null;normal_payable_sum:string;deferred_sum:string;probation_payment_sum:string;payable_sum:string;net_amount:string|null}[] settlement_month: string params: { team_text: string diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index d9f5e50..5f90a06 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -3,6 +3,10 @@ import { ElMessage } from 'element-plus' import { clearToken, getToken } from '@/utils/storage' +declare module 'axios' { + interface AxiosRequestConfig { silentError?: boolean } +} + /** 后端统一响应信封(见 PROJECT_STANDARDS 第 3 节) */ export interface ApiResult { code: number @@ -58,7 +62,7 @@ service.interceptors.response.use( const body = response.data if (body.code !== 200) { // 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况 - ElMessage.error(body.message || '请求失败') + if (!response.config.silentError) ElMessage.error(body.message || '请求失败') return Promise.reject(new Error(body.message || '请求失败')) } return response @@ -84,7 +88,10 @@ service.interceptors.response.use( // 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403, // 因此不会出现「用户输错一次密码就被弹回登录页」的循环。 handleUnauthorized(message) - } else { + } else if (status === 403) { + if (!error.config?.url?.endsWith('/auth/me')) window.dispatchEvent(new CustomEvent('authorization-refresh')) + if (!error.config?.silentError) ElMessage.error(message) + } else if (!error.config?.silentError && error.code !== 'ERR_CANCELED') { ElMessage.error(message) } return Promise.reject(error) diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts index b459a27..d6a694f 100644 --- a/frontend/src/api/roles.ts +++ b/frontend/src/api/roles.ts @@ -5,6 +5,7 @@ import type { PageData, RoleOption } from './types' export interface RoleItem extends RoleOption { /** 绑定用户数:>0 的角色不可删除,前端据此禁用按钮 */ user_count: number + permission_count: number } export interface RoleQueryParams { @@ -19,6 +20,8 @@ export interface RoleCreateParams { role_name: string description?: string sort_order?: number + permission_codes?: string[] + reason?: string } /** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */ diff --git a/frontend/src/api/settlements.ts b/frontend/src/api/settlements.ts index 6b67f4e..cd35084 100644 --- a/frontend/src/api/settlements.ts +++ b/frontend/src/api/settlements.ts @@ -36,6 +36,10 @@ export interface SettlementItem { net_amount: string | null is_probation: number probation_note: string | null + normal_payable_sum: string + deferred_sum: string + probation_payment_sum: string + snapshot_json?: Record | null } export interface Settlement { diff --git a/frontend/src/api/tplus.ts b/frontend/src/api/tplus.ts index d2cbff3..8c110ff 100644 --- a/frontend/src/api/tplus.ts +++ b/frontend/src/api/tplus.ts @@ -36,6 +36,23 @@ export interface TPlusBatch { error_summary: string | null trigger_type?: 'MANUAL' | 'SCHEDULED' | 'AUTO_RETRY' automatic_attempt?: number + progress?: SyncProgress | null +} +export interface EntityProgress { + entity_type: EntityType + total_records: number | null + processed_records: number | null + status: string +} +export interface SyncProgress { + phase: string + total_records: number | null + processed_records: number + percentage: number | null + active_entity: EntityType | null + updated_at: string | null + elapsed_seconds: number + entities: EntityProgress[] } export interface TPlusDetail extends TPlusBatch { audit_result: AuditReport | null } export interface TPlusError { @@ -52,6 +69,7 @@ export interface TPlusOverview { configuration_message: string status_counts: Partial> total_staged_records: number + current_batch: TPlusBatch | null compatibility_status: string business_import_enabled: boolean latest_audit: AuditReport | null @@ -59,6 +77,9 @@ export interface TPlusOverview { } export interface AutomationStatus { enabled: boolean + version: number + editable: boolean + configuration_origin: 'ENVIRONMENT' | 'SAVED' weekday: number time: string timezone: string @@ -75,14 +96,18 @@ export interface CreateTask { task_type: TaskType; entity_types: EntityType[]; r const base = '/v1/system/tplus' /** 查询配置和任务状态;不会触发源库连接。 */ -export function getOverview(): Promise { return request({ url: `${base}/overview` }) } +type ReadOptions = { silentError?: boolean; signal?: AbortSignal } +export function getOverview(options: ReadOptions = {}): Promise { return request({ url: `${base}/overview`, ...options }) } /** 分页查询任务。 */ -export function listBatches(params: BatchQuery): Promise> { - return request({ url: `${base}/batches`, params }) +export function listBatches(params: BatchQuery, options: ReadOptions = {}): Promise> { + return request({ url: `${base}/batches`, params, ...options }) +} +export function updateAutomation(data: { enabled: boolean; expected_version: number }): Promise { + return request({ url: `${base}/automation`, method: 'put', data }) } /** 按需查询报告。 */ -export function getBatch(number: string): Promise { - return request({ url: `${base}/batches/${encodeURIComponent(number)}` }) +export function getBatch(number: string, options: ReadOptions = {}): Promise { + return request({ url: `${base}/batches/${encodeURIComponent(number)}`, ...options }) } /** 分页查询固定错误说明。 */ export function listErrors(number: string, params: ErrorQuery): Promise> { diff --git a/frontend/src/assets/styles/authorization-motion.css b/frontend/src/assets/styles/authorization-motion.css new file mode 100644 index 0000000..21de918 --- /dev/null +++ b/frontend/src/assets/styles/authorization-motion.css @@ -0,0 +1,15 @@ +/* 授权结果更新的轻反馈;不延迟数据和表单操作。 */ +.authorization-feedback-enter-active, .authorization-feedback-leave-active { + transition: opacity 120ms ease, transform 120ms ease; +} +.authorization-feedback-enter-from, .authorization-feedback-leave-to { opacity: 0; transform: translateY(2px); } +.authorization-editor .el-tag, .authorization-editor .el-select__wrapper { + transition: color 120ms, background-color 120ms, border-color 120ms, box-shadow 120ms; +} +.authorization-editor { --el-transition-duration: 180ms; } +@media (prefers-reduced-motion: reduce) { + .authorization-feedback-enter-active, .authorization-feedback-leave-active, + .authorization-editor .el-tag, .authorization-editor .el-select__wrapper { + transition: none !important; transform: none !important; + } +} diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index 57f0b10..a901b12 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -154,7 +154,7 @@ /* ── 字体 ───────────────────────────────────────────────────────────── */ --el-font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC', - 'Helvetica Neue', Arial, sans-serif; + sans-serif; } * { @@ -172,7 +172,7 @@ body, body { /* 与品牌栈同源:开源字体优先;Helvetica/Arial 仅为西文回退(系统自带,不涉字体文件分发) */ font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC', - 'Helvetica Neue', Arial, sans-serif; + sans-serif; font-size: 14px; color: var(--ys-ink); background-color: var(--ys-mist); @@ -455,26 +455,16 @@ span.cell-num { /* 主按钮:靛蓝微渐变 + 顶部内高光,悬停抬高 1px 并扩散柔影 */ .el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled) { background-color: var(--ys-indigo); - background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo)); border-color: var(--ys-indigo); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.18), - 0 1px 2px rgba(46, 75, 168, 0.22); } .el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):hover { - background-image: linear-gradient(180deg, #31509f, var(--ys-indigo-deep)); + background-color: var(--ys-indigo-deep); border-color: var(--ys-indigo-deep); - transform: translateY(-1px); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.2), - 0 4px 12px rgba(46, 75, 168, 0.3); } .el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):active { - transform: translateY(0); - background-image: linear-gradient(180deg, #2b4699, #1f3478); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); + background-color: var(--ys-indigo-deep); } .el-button--primary.is-disabled, @@ -497,9 +487,8 @@ span.cell-num { .is-text, .is-plain ) { - background-image: linear-gradient(180deg, var(--ys-paper), #fbfcfe); + background: var(--ys-paper); border-color: var(--ys-line); - box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03); } .el-button:not( @@ -513,7 +502,7 @@ span.cell-num { .is-plain ):hover { color: var(--ys-indigo); - background-image: linear-gradient(180deg, var(--ys-paper), #f8fafd); + background: var(--ys-paper); border-color: rgba(46, 75, 168, 0.45); } @@ -521,9 +510,8 @@ span.cell-num { .el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled) { color: var(--ys-danger); background-color: var(--ys-paper); - background-image: linear-gradient(180deg, var(--ys-paper), #fdfbfb); + background: var(--ys-paper); border-color: rgba(201, 79, 79, 0.28); - box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03); } .el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled):hover { @@ -615,11 +603,7 @@ span.cell-num { .el-pagination.is-background .el-pager li.is-active { color: #fff; background-color: var(--ys-indigo); - background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo)); border: 1px solid var(--ys-indigo); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.18), - 0 1px 2px rgba(46, 75, 168, 0.22); } /* 标签:默认压成 11px 方角小签(状态列请改用 .ys-pill) */ @@ -718,7 +702,7 @@ span.cell-num { } .el-message-box { - max-width: calc(100vw - 32px); + max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px)); } .el-message-box__btns { @@ -742,6 +726,22 @@ span.cell-num { line-height: 1.7; } +/* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */ +.record-form .el-form-item__label { + height: auto; + min-height: 32px; + line-height: 1.5; + align-items: center; +} +.record-form .el-date-editor.el-input, +.record-form .el-date-editor.el-input__wrapper { width: 100%; } +.record-form .form-hint { width: 100%; } +@media (max-width: 600px) { + .record-form .el-form-item { display: block; } + .record-form .el-form-item__label { width: auto !important; padding: 0; justify-content: flex-start; } + .record-form .el-form-item__content { margin-left: 0 !important; } +} + @media (max-width: 600px) { .el-dialog:not(.is-fullscreen) { --ys-dialog-top-gap: 12px; diff --git a/frontend/src/components/authorization/CreateRoleDrawer.vue b/frontend/src/components/authorization/CreateRoleDrawer.vue new file mode 100644 index 0000000..1e56a65 --- /dev/null +++ b/frontend/src/components/authorization/CreateRoleDrawer.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/authorization/PermissionDrawer.vue b/frontend/src/components/authorization/PermissionDrawer.vue new file mode 100644 index 0000000..f7784f4 --- /dev/null +++ b/frontend/src/components/authorization/PermissionDrawer.vue @@ -0,0 +1,99 @@ + + + + + + diff --git a/frontend/src/components/authorization/PermissionMatrix.vue b/frontend/src/components/authorization/PermissionMatrix.vue new file mode 100644 index 0000000..f781032 --- /dev/null +++ b/frontend/src/components/authorization/PermissionMatrix.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/components/authorization/usePermissionEditor.ts b/frontend/src/components/authorization/usePermissionEditor.ts new file mode 100644 index 0000000..0529847 --- /dev/null +++ b/frontend/src/components/authorization/usePermissionEditor.ts @@ -0,0 +1,89 @@ +import { computed, ref, watch } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import * as api from '@/api/authorization' +import { listRoles, type RoleItem } from '@/api/roles' +import { useUserStore } from '@/store/user' +import { handledError } from '@/utils/error' + +export function usePermissionEditor(props: { id: number | null; kind: 'user' | 'role' }, open: { value: boolean }, saved: () => void) { + const user = useUserStore() + const catalog = ref([]), roles = ref([]) + const selectedRoles = ref([]), selectedCodes = ref([]), originalRoles = ref([]) + const originalCodes = ref([]), revision = ref(0), reason = ref(''), result = ref(null) + const loading = ref(false), saving = ref(false), failed = ref(false), fixedRole = ref(false), copying = ref(false) + const previewing = ref(false), previewFailed = ref(false), stale = ref(false) + let sequence = 0, previewSequence = 0 + const effective = computed(() => props.kind === 'user' ? result.value?.permissions ?? [] : selectedCodes.value) + const added = computed(() => effective.value.filter(code => !originalCodes.value.includes(code))) + const removed = computed(() => originalCodes.value.filter(code => !effective.value.includes(code))) + const roleChanged = computed(() => [...selectedRoles.value].sort().join('|') !== [...originalRoles.value].sort().join('|')) + const dirty = computed(() => Boolean(added.value.length || removed.value.length || (props.kind === 'user' && roleChanged.value))) + const canSave = computed(() => !loading.value && !failed.value && !stale.value && !copying.value && !previewing.value && !previewFailed.value && !saving.value && !(props.kind === 'role' && fixedRole.value) && dirty.value && Boolean(reason.value.trim())) + async function load(): Promise { + if (!props.id || !open.value) return + const version = ++sequence + ++previewSequence + loading.value = true; failed.value = false; stale.value = false; previewFailed.value = false + reason.value = ''; result.value = null; selectedCodes.value = []; selectedRoles.value = []; fixedRole.value = false + try { + const [permissions, rolePage, data] = await Promise.all([api.catalog(), listRoles({ pageSize: 200 }), props.kind === 'user' ? api.getUser(props.id) : api.getRole(props.id)]) + if (version !== sequence || !open.value) return + catalog.value = permissions; roles.value = rolePage.list; revision.value = data.revision + if ('role_codes' in data) { + selectedRoles.value = [...data.role_codes]; originalRoles.value = [...data.role_codes] + result.value = data; originalCodes.value = [...data.permissions] + } else { + selectedCodes.value = [...data.permission_codes]; originalCodes.value = [...data.permission_codes]; fixedRole.value = data.fixed + } + } catch (error) { if (version === sequence) { failed.value = true; handledError(error) } } + finally { if (version === sequence) loading.value = false } + } + async function preview(): Promise { + if (!props.id) return false + const version = ++previewSequence + previewing.value = true; previewFailed.value = false + try { + const response = await api.previewUser(props.id, { role_codes: selectedRoles.value, overrides: [] }) + if (version !== previewSequence || !open.value) return false + result.value = response + return true + } catch (error) { if (version === previewSequence) { previewFailed.value = true; handledError(error) }; return false } + finally { if (version === previewSequence) previewing.value = false } + } + async function copyRole(id: number): Promise { + copying.value = true + try { selectedCodes.value = (await api.getRole(id)).permission_codes.filter(code => catalog.value.some(item => item.code === code && !item.admin_only)); ElMessage.success('权限已复制,可继续调整后保存') } + catch (error) { handledError(error) } + finally { copying.value = false } + } + async function save(): Promise { + if (!props.id || !canSave.value) return + saving.value = true + try { + if (props.kind === 'user') { + if (!await preview()) return + await api.saveUser(props.id, { role_codes: selectedRoles.value, overrides: [], expected_revision: revision.value, reason: reason.value.trim() }) + } else await api.saveRole(props.id, { permission_codes: selectedCodes.value, expected_revision: revision.value, reason: reason.value.trim() }) + open.value = false; saved() + window.dispatchEvent(new CustomEvent('authorization-changed')) + try { await user.fetchUserInfo() } catch (error) { handledError(error) } + ElMessage.success(props.kind === 'user' ? '角色已分配,继承权限即时生效' : '角色权限已保存,绑定用户同步生效') + } catch (error) { + if ((error as { response?: { status?: number } }).response?.status === 409) stale.value = true + handledError(error) + } finally { saving.value = false } + } + async function beforeClose(done: () => void): Promise { + if (saving.value) return + if (dirty.value && !fixedRole.value) { + try { await ElMessageBox.confirm('当前修改尚未保存,关闭后将放弃这些修改。', '放弃未保存修改', { confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }) } + catch { return } + } + ++sequence; ++previewSequence; done() + } + function roleLabel(code: string): string { return roles.value.find(role => role.role_code === code)?.role_name ?? code } + watch([() => open.value, () => props.id, () => props.kind], ([value]) => { if (value) void load(); else { ++sequence; ++previewSequence } }) + return { user, catalog, roles, selectedRoles, selectedCodes, originalRoles, reason, result, loading, saving, + failed, fixedRole, copying, previewing, previewFailed, stale, effective, added, removed, dirty, canSave, + load, preview, copyRole, save, beforeClose, roleLabel } +} diff --git a/frontend/src/components/coefficients/CoefficientPanel.vue b/frontend/src/components/coefficients/CoefficientPanel.vue new file mode 100644 index 0000000..b62d84a --- /dev/null +++ b/frontend/src/components/coefficients/CoefficientPanel.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/components/feedback/PayrollAccessNotice.vue b/frontend/src/components/feedback/PayrollAccessNotice.vue new file mode 100644 index 0000000..2c290e9 --- /dev/null +++ b/frontend/src/components/feedback/PayrollAccessNotice.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/src/components/layout/AppMenu.vue b/frontend/src/components/layout/AppMenu.vue index d5e92d9..a4d341c 100644 --- a/frontend/src/components/layout/AppMenu.vue +++ b/frontend/src/components/layout/AppMenu.vue @@ -137,11 +137,7 @@ const { topLevelMenus, groupedMenus } = useAppMenu() .app-menu :deep(.el-menu-item.is-active) { font-weight: 500; color: var(--ys-indigo); - background: linear-gradient(180deg, var(--ys-paper), #fbfcfe); - box-shadow: - inset 0 1px 0 #fff, - 0 1px 2px rgba(28, 35, 51, 0.06), - 0 4px 10px rgba(28, 35, 51, 0.06); + background: var(--ys-paper); } .app-menu :deep(.el-sub-menu__title:hover) { diff --git a/frontend/src/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue index f7436bc..1e55f15 100644 --- a/frontend/src/components/layout/BasicLayout.vue +++ b/frontend/src/components/layout/BasicLayout.vue @@ -1,10 +1,10 @@ + + diff --git a/frontend/src/components/products/ProductExtractDialog.vue b/frontend/src/components/products/ProductExtractDialog.vue new file mode 100644 index 0000000..252b103 --- /dev/null +++ b/frontend/src/components/products/ProductExtractDialog.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2908410..83bdc5a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,5 +9,6 @@ import pinia from './store' import 'element-plus/dist/index.css' import './assets/styles/index.css' import './assets/styles/table-actions.css' +import './assets/styles/authorization-motion.css' createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app') diff --git a/frontend/src/router/access.ts b/frontend/src/router/access.ts index a7b9470..72b2c32 100644 --- a/frontend/src/router/access.ts +++ b/frontend/src/router/access.ts @@ -2,7 +2,7 @@ import type { RouteRecordNormalized, Router } from 'vue-router' import { useUserStore } from '@/store/user' -import { ROLE_ADMIN } from '@/utils/constants' +import { reportPermissions, routePermissions } from './permissions' /** * 路由准入与落地页解析(菜单、守卫、登录回跳共用一份规则)。 @@ -19,16 +19,11 @@ export function isMenuRecord(record: RouteRecordNormalized): boolean { /** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */ export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean { + void roles if (record.meta.public) return false - if (record.meta.requiresPayrollApproval && !useUserStore().userInfo?.payroll_approval_enabled) return false - if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN) && !(record.meta.allowPayrollMaker && useUserStore().canWritePayroll)) return false - if ( - record.meta.allowedRoles && - !record.meta.allowedRoles.some((role) => roles.includes(role)) - ) { - return false - } - return true + if (record.path === '/no-access') return true + const permission = routePermissions[record.path] + return !!permission && useUserStore().hasPermission(permission) } /** @@ -54,12 +49,17 @@ export function firstAccessiblePath(router: Router, roles: string[]): string { .filter(isMenuRecord) .filter((record) => canAccessRoute(record, roles)) .sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99)) - return fallback[0]?.path ?? '/login' + return fallback[0]?.path ?? '/no-access' } /** 登录回跳目标是否可信且可访问(防开放重定向与回跳受限页) */ export function canAccessPath(router: Router, path: string, roles: string[]): boolean { if (!path.startsWith('/') || path.startsWith('//')) return false + const resolved = router.resolve(path) + if (resolved.name === 'report-print') { + const resource = reportPermissions[String(resolved.params.code)] + return !!resource && useUserStore().hasPermission(resource + ':export') + } const record = matchedRecord(router, path) return Boolean(record && isMenuRecord(record) && canAccessRoute(record, roles)) } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2d26d6c..7d396fe 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,7 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { ElMessage } from 'element-plus' import type { MenuIconName } from '@/components/layout/menuIcons' -import { firstAccessiblePath } from '@/router/access' +import { canAccessPath, firstAccessiblePath } from '@/router/access' import { useUserStore } from '@/store/user' import { ROLE_ADMIN } from '@/utils/constants' @@ -46,6 +46,15 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/components/layout/BasicLayout.vue'), redirect: '/dashboard', children: [ + { + path: '/no-access', name: 'no-access', component: () => import('@/views/no-access/index.vue'), + meta: {title: '暂无可访问功能', menu: false}, + }, + { + path: '/system/authorization', name: 'system-authorization', + redirect: '/system/roles', + meta: {menu: false}, + }, { // 工作台(M4 ⑧):与报表/审批接口同权限(admin/finance/leader), // 前后端双层;营销后勤可见性待权限清单 #6 答复 @@ -82,7 +91,7 @@ const routes: RouteRecordRaw[] = [ name: 'system-business-roles', component: () => import('@/views/system/business-roles/index.vue'), meta: { - title: '岗位角色', + title: '计提岗位', icon: 'business-roles', group: '系统管理', requiresAdmin: true, @@ -141,6 +150,12 @@ const routes: RouteRecordRaw[] = [ order: 9, }, }, + { + path: '/system/monitor', + name: 'system-monitor', + component: () => import('@/views/system/monitor/index.vue'), + meta: { title: '系统监控', icon: 'monitor', group: '系统管理', requiresAdmin: true, order: 11 }, + }, { // 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base) path: '/base/personnel', @@ -298,22 +313,10 @@ router.beforeEach(async (to) => { } } - if (to.meta.requiresPayrollApproval && !userStore.userInfo?.payroll_approval_enabled) { - return firstAccessiblePath(router, userStore.roles) - } - - if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN) && !(to.meta.allowPayrollMaker && userStore.canWritePayroll)) { - const home = firstAccessiblePath(router, userStore.roles) - // 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示 - if (!to.redirectedFrom) ElMessage.error('需要管理员权限') - return home === to.path ? { path: '/login' } : { path: home } - } - - // 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线 - if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) { + if (to.path !== '/no-access' && !canAccessPath(router, to.fullPath, userStore.roles)) { const home = firstAccessiblePath(router, userStore.roles) if (!to.redirectedFrom) ElMessage.error('无权访问该页面') - return home === to.path ? { path: '/login' } : { path: home } + return home === to.path ? { path: '/no-access' } : { path: home } } return true diff --git a/frontend/src/router/permissions.ts b/frontend/src/router/permissions.ts new file mode 100644 index 0000000..b287ced --- /dev/null +++ b/frontend/src/router/permissions.ts @@ -0,0 +1,16 @@ +export const routePermissions: Record = { + '/dashboard': 'dashboard:view', '/system/users': 'system:users:view', '/system/roles': 'system:roles:view', + '/system/business-roles': 'system:business_roles:view', + '/system/params': 'system:params:view', '/system/settings': 'system:settings:view', '/system/logs': 'system:logs:view', + '/system/tplus': 'tplus:view', '/system/approvals': 'approvals:view', + '/system/monitor': 'system:monitor:view', + '/base/personnel': 'base:personnel:view', '/base/products': 'base:products:view', '/base/customers': 'base:customers:view', + '/business/businesses': 'business:businesses:view', '/business/shipments': 'business:shipments:view', '/business/receipts': 'business:receipts:view', + '/calc/ledger': 'payroll:view', '/settlements': 'settlements:view', + '/reports/personal-detail': 'reports:personal:view', '/reports/department-summary': 'reports:department:view', + '/reports/business-stats': 'reports:business:view', '/reports/payment-stats': 'reports:payments:view', +} +export const reportPermissions: Record = { + 'personal-detail': 'reports:personal', 'department-summary': 'reports:department', + 'business-stats': 'reports:business', 'payment-stats': 'reports:payments', +} diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts index fc55f6b..d9e62c8 100644 --- a/frontend/src/store/user.ts +++ b/frontend/src/store/user.ts @@ -17,6 +17,12 @@ export const useUserStore = defineStore('user', () => { const canWritePayroll = computed(() => userInfo.value?.can_write_payroll === true) const roles = computed(() => userInfo.value?.roles ?? []) const displayName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '') + function hasPermission(code: string): boolean { return userInfo.value?.permissions?.includes(code) === true } + function permissionReason(code: string): string { + if (!hasPermission(code)) return '当前账号没有此操作权限,请联系管理员授权' + return '' + } + function can(code: string): boolean { return permissionReason(code) === '' } /** 登录并保存令牌 */ async function login(params: authApi.LoginParams): Promise { @@ -50,5 +56,5 @@ export const useUserStore = defineStore('user', () => { clearToken() } - return { token, userInfo, canWritePayroll, roles, displayName, login, fetchUserInfo, logout, resetAuth } + return { token, userInfo, canWritePayroll, roles, displayName, hasPermission, can, permissionReason, login, fetchUserInfo, logout, resetAuth } }) diff --git a/frontend/src/views/base/customers/RatingCalculatorDialog.vue b/frontend/src/views/base/customers/RatingCalculatorDialog.vue index ffee2ff..36f00ab 100644 --- a/frontend/src/views/base/customers/RatingCalculatorDialog.vue +++ b/frontend/src/views/base/customers/RatingCalculatorDialog.vue @@ -1,11 +1,11 @@ diff --git a/frontend/src/views/system/monitor/index.vue b/frontend/src/views/system/monitor/index.vue new file mode 100644 index 0000000..0aafd49 --- /dev/null +++ b/frontend/src/views/system/monitor/index.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/frontend/src/views/system/monitor/labels.ts b/frontend/src/views/system/monitor/labels.ts new file mode 100644 index 0000000..04932b7 --- /dev/null +++ b/frontend/src/views/system/monitor/labels.ts @@ -0,0 +1,12 @@ +import type { MonitorStatus } from '@/api/monitor' + +export const statusLabels: Record = { normal: '正常', warning: '关注', error: '异常', unknown: '未知' } +export const statusTypes = { normal: 'success', warning: 'warning', error: 'danger', unknown: 'info' } as const +export const kindLabels: Record = { HEALTH: '健康异常', REQUEST_ERROR: '接口异常', SLOW_REQUEST: '慢请求', RUNTIME_ERROR: '后台异常' } +export const phases: Record = { IDLE: '等待任务', RUNNING: '处理中', STOPPED: '已停止', UNKNOWN: '尚无记录' } +export const number = (value: number | null | undefined, suffix = ''): string => + value == null ? '—' : `${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}${suffix}` +export function uptime(seconds: number): string { + const days = Math.floor(seconds / 86400) + return `${days ? `${days} 天 ` : ''}${Math.floor(seconds % 86400 / 3600)} 小时 ${Math.floor(seconds % 3600 / 60)} 分钟` +} diff --git a/frontend/src/views/system/monitor/useMonitor.ts b/frontend/src/views/system/monitor/useMonitor.ts new file mode 100644 index 0000000..c1d1e79 --- /dev/null +++ b/frontend/src/views/system/monitor/useMonitor.ts @@ -0,0 +1,124 @@ +import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' +import { ElMessage } from 'element-plus' +import * as api from '@/api/monitor' +import { handledError } from '@/utils/error' + +export function useMonitor() { + const tab = ref('overview') + const overview = ref(null) + const loading = ref(false), failed = ref(false), autoRefresh = ref(true) + const lastConnected = ref(null) + const trends = ref(null) + const interfaces = ref([]), truncated = ref(false) + const events = ref([]), total = ref(0), detail = ref(null) + const drawer = ref(false), detailLoading = ref(false), detailFailed = ref(false) + const contentLoading = ref(false), contentFailed = ref(false), downloading = ref(false) + const hours = ref<1 | 24 | 168>(1), end = ref(null) + const route = ref(''), kind = ref(''), requestId = ref(''), pageNum = ref(1), pageSize = ref(20) + const isHistorical = computed(() => Boolean(end.value)) + let timer: ReturnType | undefined + let disposed = false, contentVersion = 0, detailVersion = 0 + const controllers = new Set() + const windowQuery = (): api.MonitorWindow => ({ hours: hours.value, end: end.value || undefined, route: route.value || undefined }) + const validRequestId = (): boolean => !requestId.value.trim() || /^[a-f0-9]{32}$/.test(requestId.value.trim()) + const eventQuery = (): api.EventQuery => ({ ...windowQuery(), kind: kind.value || undefined, + request_id: requestId.value.trim() || undefined, pageNum: pageNum.value, pageSize: pageSize.value }) + function controller(): AbortController { const result = new AbortController(); controllers.add(result); return result } + async function loadOverview(): Promise { + if (loading.value || disposed) return + loading.value = true + const control = controller() + try { + overview.value = await api.getOverview(control.signal) + lastConnected.value = new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace(' ', 'T') + failed.value = false + } catch (error) { + if (!control.signal.aborted && !disposed) { failed.value = true; handledError(error) } + } finally { controllers.delete(control); loading.value = false } + } + async function loadContent(): Promise { + if (disposed || tab.value === 'tasks') return + if (tab.value === 'events' && !validRequestId()) { + ElMessage.warning('请求编号应为 32 位小写字母和数字,请复制完整编号。'); return + } + const version = ++contentVersion, control = controller(), target = tab.value + contentLoading.value = true; contentFailed.value = false + try { + if (target === 'overview') { + const result = await api.getTrends(windowQuery(), control.signal) + if (version === contentVersion) trends.value = result + } else if (target === 'interfaces') { + const result = await api.getInterfaces(windowQuery(), control.signal) + if (version === contentVersion) { interfaces.value = result.list; truncated.value = result.truncated } + } else { + const result = await api.getEvents(eventQuery(), control.signal) + if (version === contentVersion) { events.value = result.list; total.value = result.total } + } + } catch (error) { + if (!control.signal.aborted && !disposed && version === contentVersion) { contentFailed.value = true; handledError(error) } + } finally { controllers.delete(control); if (version === contentVersion) contentLoading.value = false } + } + async function refresh(): Promise { await Promise.allSettled([loadOverview(), loadContent()]) } + function schedule(): void { + clearTimeout(timer) + if (!autoRefresh.value || document.hidden || disposed) return + timer = setTimeout(async () => { + await loadOverview() + // 历史范围、接口排行、事件列表和打开的详情保持用户当前查看状态。 + if (tab.value === 'overview' && !isHistorical.value && !drawer.value) await loadContent() + schedule() + }, 15000) + } + function visibility(): void { schedule() } + function search(): void { pageNum.value = 1; void loadContent() } + function live(): void { end.value = null; search() } + async function openEvent(item: api.MonitorEvent): Promise { + const version = ++detailVersion + drawer.value = true; detail.value = item; detailFailed.value = false; detailLoading.value = false + if (!item.id) return + detailLoading.value = true + const control = controller() + try { + const result = await api.getEvent(item.id, control.signal) + if (version === detailVersion) detail.value = result + } catch (error) { + if (!control.signal.aborted && version === detailVersion) { detailFailed.value = true; handledError(error) } + } finally { controllers.delete(control); if (version === detailVersion) detailLoading.value = false } + } + function inspectRoute(item: api.InterfaceMetric): void { + route.value = item.route; kind.value = 'REQUEST_ERROR'; tab.value = 'events'; pageNum.value = 1 + void loadContent() + } + function inspectTime(at: string): void { + const date = new Date(at.length === 19 ? `${at}+08:00` : at) + date.setTime(date.getTime() + (trends.value?.step_minutes ?? 1) * 60000) + end.value = new Date(Math.min(date.getTime(), Date.now())).toISOString() + hours.value = 1; route.value = ''; tab.value = 'events'; pageNum.value = 1 + void loadContent() + } + async function copy(value: string): Promise { + try { await navigator.clipboard.writeText(value); ElMessage.success('请求编号已复制') } + catch (error) { handledError(error); ElMessage.warning('复制失败,请选中编号手动复制。') } + } + async function download(): Promise { + if (!validRequestId()) { ElMessage.warning('请填写完整的请求编号后下载。'); return } + downloading.value = true + try { + const blob = await api.downloadDiagnostic(eventQuery()), url = URL.createObjectURL(blob) + const link = document.createElement('a'); link.href = url; link.download = '系统监控诊断摘要.json' + link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) + } catch (error) { handledError(error); ElMessage.error('诊断摘要下载失败,请稍后重试。') } + finally { downloading.value = false } + } + watch(autoRefresh, schedule) + onMounted(() => { void refresh(); schedule(); document.addEventListener('visibilitychange', visibility) }) + onBeforeUnmount(() => { + disposed = true; clearTimeout(timer); contentVersion++; detailVersion++ + document.removeEventListener('visibilitychange', visibility) + for (const control of controllers) control.abort() + }) + return reactive({ tab, overview, loading, failed, autoRefresh, lastConnected, trends, interfaces, + truncated, events, total, detail, drawer, detailLoading, detailFailed, contentLoading, + contentFailed, downloading, hours, end, route, kind, requestId, pageNum, pageSize, + isHistorical, refresh, loadContent, search, live, openEvent, inspectRoute, inspectTime, copy, download }) +} diff --git a/frontend/src/views/system/params/index.vue b/frontend/src/views/system/params/index.vue index d5a8530..befd030 100644 --- a/frontend/src/views/system/params/index.vue +++ b/frontend/src/views/system/params/index.vue @@ -1,6 +1,11 @@ @@ -523,32 +563,33 @@ onMounted(() => { type="primary" size="small" @click="openEdit(item, '')" - > + :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')"> 设置 - 历史 + 变更历史 + -
+
待提交变更({{ pendingList.length }} 项) - 提交前不会生效;提交后未生效前可整批撤回 + 暂存内容尚未生效。请确认参数值与生效日期后提交。
- + @@ -583,22 +624,22 @@ onMounted(() => { /> - 提交变更 + 提交变更
- + @@ -679,13 +724,13 @@ onMounted(() => { style="width: 150px" /> 对比 - 导出记录 + 导出记录 @@ -759,7 +804,7 @@ onMounted(() => { - + @@ -785,6 +830,7 @@ onMounted(() => { +

按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。

@@ -838,7 +884,7 @@ onMounted(() => { - 试 算 + 开始试算 @@ -868,6 +914,7 @@ onMounted(() => {
+ diff --git a/frontend/src/views/system/tplus/CreateTaskDialog.vue b/frontend/src/views/system/tplus/CreateTaskDialog.vue index 0c4c1be..63711e2 100644 --- a/frontend/src/views/system/tplus/CreateTaskDialog.vue +++ b/frontend/src/views/system/tplus/CreateTaskDialog.vue @@ -24,7 +24,7 @@ async function submit(): Promise { entity_types: taskType.value === 'AUDIT' ? [] : [...selected.value], request_key: requestKey }) requestKey = newRequestKey() visible.value = false - ElMessage.success('任务已排队,处理完成后可刷新查看') + ElMessage.success('任务已提交,处理结果会自动更新') emit('created', batch) } catch (error) { handledError(error) } finally { pending.value = false } @@ -45,11 +45,11 @@ async function submit(): Promise { {{ item.label }} -

{{ taskType === 'READ' ? '全量读取所选范围,重复快照自动跳过。只保存原始数据,不写入工资业务表。' : '核对收款方向、单据类型及订单关联,只返回字典和统计数量。' }}

+

{{ taskType === 'READ' ? '读取所选范围内的数据,自动跳过未变化的记录。读取完成后可预览导入,不会自动生成工资。' : '检查收付款单据与订单、销货单的关联情况,供导入前核对;不会修改业务数据。' }}

diff --git a/frontend/src/views/system/tplus/FactEditor.vue b/frontend/src/views/system/tplus/FactEditor.vue index bc263b5..9bdbf37 100644 --- a/frontend/src/views/system/tplus/FactEditor.vue +++ b/frontend/src/views/system/tplus/FactEditor.vue @@ -1,6 +1,9 @@ @@ -351,9 +354,10 @@ onMounted(() => { - +