feat(前端): 配套授权、监控与同步进度页面
- 角色授权:权限矩阵抽屉与角色创建,按模块分组勾选 - 系统监控:总览、趋势、接口与事件页,15 秒自动刷新, 请求可中断、窗口切换暂停,详情抽屉支持导出 - T+ 同步:同步进度条组件与工作台状态拆分 - 工资:试用期面板、产品提取弹窗与无权限占位页 - 路由权限:按权限码控制菜单与页面可达性 影响范围:前端全部页面 验证:随本次后端联调本地人工核对,e2e 脚本入库待跑
This commit is contained in:
parent
1461a8d113
commit
a2f9a58de5
@ -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`。
|
||||
|
||||
## 职责
|
||||
|
||||
126
frontend/e2e/authorization_roles.mjs
Normal file
126
frontend/e2e/authorization_roles.mjs
Normal file
@ -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() }
|
||||
@ -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
|
||||
|
||||
125
frontend/e2e/monitor.mjs
Normal file
125
frontend/e2e/monitor.mjs
Normal file
@ -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() }
|
||||
@ -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"]:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
23
frontend/src/api/authorization.ts
Normal file
23
frontend/src/api/authorization.ts
Normal file
@ -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<string, {roles: string[]; override: string | null; effective: boolean}>
|
||||
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<PermissionItem[]> => request({url: `${base}/permissions/catalog`})
|
||||
export const getUser = (id: number): Promise<UserAuthorization> => request({url: `${base}/users/${id}/authorization`})
|
||||
export const previewUser = (id: number, data: {role_codes: string[]; overrides: Override[]}): Promise<AuthorizationResult> => 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<UserAuthorization> => request({url: `${base}/users/${id}/authorization`, method: 'put', data})
|
||||
export const getRole = (id: number): Promise<RolePermissions> => request({url: `${base}/roles/${id}/permissions`})
|
||||
export const saveRole = (id: number, data: {permission_codes: string[]; expected_revision: number; reason: string}): Promise<RolePermissions> => request({url: `${base}/roles/${id}/permissions`, method: 'put', data})
|
||||
@ -101,3 +101,70 @@ export function setBusinessRoleStatus(
|
||||
export function listTemplateOptions(): Promise<TemplateOption[]> {
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
description?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export interface CalcTemplateUpdateParams {
|
||||
template_name: string
|
||||
params: Record<string, string>
|
||||
description?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
/** 公式模板列表(含停用与岗位引用计数) */
|
||||
export function listCalcTemplates(): Promise<CalcTemplateItem[]> {
|
||||
return request({ url: '/v1/system/calc-templates', method: 'get' })
|
||||
}
|
||||
|
||||
/** 公式骨架下拉(新增模板时选择) */
|
||||
export function listFormulaOptions(): Promise<FormulaOption[]> {
|
||||
return request({ url: '/v1/system/calc-templates/meta/formulas', method: 'get' })
|
||||
}
|
||||
|
||||
/** 新增公式模板 */
|
||||
export function createCalcTemplate(data: CalcTemplateCreateParams): Promise<CalcTemplateItem> {
|
||||
return request({ url: '/v1/system/calc-templates', method: 'post', data })
|
||||
}
|
||||
|
||||
/** 编辑公式模板(编码与骨架锁定) */
|
||||
export function updateCalcTemplate(
|
||||
templateId: number,
|
||||
data: CalcTemplateUpdateParams,
|
||||
): Promise<CalcTemplateItem> {
|
||||
return request({ url: `/v1/system/calc-templates/${templateId}`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 删除公式模板(系统模板与被岗位引用的模板后端拒绝) */
|
||||
export function deleteCalcTemplate(templateId: number): Promise<null> {
|
||||
return request({ url: `/v1/system/calc-templates/${templateId}`, method: 'delete' })
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
11
frontend/src/api/metadata.ts
Normal file
11
frontend/src/api/metadata.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
export function options<T>(resource: string, params: {business_id?: number; search?: string} = {}): Promise<T[]> {
|
||||
return request({url: '/v1/meta/options', params: {resource, ...params}})
|
||||
}
|
||||
|
||||
export async function optionPage<T>(resource: string, params: {business_id?: number; search?: string} = {}): Promise<PageData<T>> {
|
||||
const list = await options<T>(resource, params)
|
||||
return {list, total: list.length, pageNum: 1, pageSize: 200}
|
||||
}
|
||||
62
frontend/src/api/monitor.ts
Normal file
62
frontend/src/api/monitor.ts
Normal file
@ -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<MonitorOverview> {
|
||||
return request({ url: `${base}/overview`, silentError: true, signal })
|
||||
}
|
||||
export function getTrends(params: MonitorWindow, signal?: AbortSignal): Promise<Trends> {
|
||||
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<PageData<MonitorEvent>> {
|
||||
return request({ url: `${base}/events`, params, silentError: true, signal })
|
||||
}
|
||||
export function getEvent(id: number, signal?: AbortSignal): Promise<MonitorEvent> {
|
||||
return request({ url: `${base}/events/${id}`, silentError: true, signal })
|
||||
}
|
||||
export async function downloadDiagnostic(params: EventQuery): Promise<Blob> {
|
||||
const result = await service.get<Blob>(`${base}/diagnostic`, { params, responseType: 'blob', silentError: true })
|
||||
return result.data
|
||||
}
|
||||
@ -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<string,string|null>[] | 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<null> {
|
||||
}
|
||||
|
||||
/** 设为在职 / 离职(离职人员不可再被业务与客户归属引用) */
|
||||
export function setPersonnelStatus(personnelId: number, status: number): Promise<PersonnelItem> {
|
||||
export function setPersonnelStatus(personnelId: number, status: number, effective_date?: string): Promise<PersonnelItem> {
|
||||
return request({
|
||||
url: `/v1/base/personnel/${personnelId}/status`,
|
||||
method: 'put',
|
||||
data: { status },
|
||||
data: { status, effective_date },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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<Pro
|
||||
data: { status },
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreProductSource(id:number): Promise<unknown> {return request({url:`/v1/base/products/${id}/restore-source`,method:'post'})}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<T = unknown> {
|
||||
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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */
|
||||
|
||||
@ -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<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface Settlement {
|
||||
|
||||
@ -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<Record<TaskStatus, number>>
|
||||
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<TPlusOverview> { return request({ url: `${base}/overview` }) }
|
||||
type ReadOptions = { silentError?: boolean; signal?: AbortSignal }
|
||||
export function getOverview(options: ReadOptions = {}): Promise<TPlusOverview> { return request({ url: `${base}/overview`, ...options }) }
|
||||
/** 分页查询任务。 */
|
||||
export function listBatches(params: BatchQuery): Promise<PageData<TPlusBatch>> {
|
||||
return request({ url: `${base}/batches`, params })
|
||||
export function listBatches(params: BatchQuery, options: ReadOptions = {}): Promise<PageData<TPlusBatch>> {
|
||||
return request({ url: `${base}/batches`, params, ...options })
|
||||
}
|
||||
export function updateAutomation(data: { enabled: boolean; expected_version: number }): Promise<AutomationStatus> {
|
||||
return request({ url: `${base}/automation`, method: 'put', data })
|
||||
}
|
||||
/** 按需查询报告。 */
|
||||
export function getBatch(number: string): Promise<TPlusDetail> {
|
||||
return request({ url: `${base}/batches/${encodeURIComponent(number)}` })
|
||||
export function getBatch(number: string, options: ReadOptions = {}): Promise<TPlusDetail> {
|
||||
return request({ url: `${base}/batches/${encodeURIComponent(number)}`, ...options })
|
||||
}
|
||||
/** 分页查询固定错误说明。 */
|
||||
export function listErrors(number: string, params: ErrorQuery): Promise<PageData<TPlusError>> {
|
||||
|
||||
15
frontend/src/assets/styles/authorization-motion.css
Normal file
15
frontend/src/assets/styles/authorization-motion.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
101
frontend/src/components/authorization/CreateRoleDrawer.vue
Normal file
101
frontend/src/components/authorization/CreateRoleDrawer.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { catalog as getCatalog, type PermissionItem } from '@/api/authorization'
|
||||
import { createRole } from '@/api/roles'
|
||||
import { handledError } from '@/utils/error'
|
||||
import PermissionMatrix from './PermissionMatrix.vue'
|
||||
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({ role_code: '', role_name: '', description: '', sort_order: 0 })
|
||||
const permissions = ref<PermissionItem[]>([]), selected = ref<string[]>([])
|
||||
const loading = ref(false), failed = ref(false), saving = ref(false)
|
||||
let sequence = 0
|
||||
const dirty = computed(() => Boolean(form.role_code || form.role_name || form.description || form.sort_order || selected.value.length))
|
||||
const canSave = computed(() => !loading.value && !failed.value && !saving.value)
|
||||
const rules: FormRules = {
|
||||
role_code: [
|
||||
{ required: true, message: '请输入角色编码', trigger: 'blur' },
|
||||
{ pattern: /^[a-z][a-z0-9_]{1,31}$/, message: '以小写字母开头,使用小写字母、数字或下划线(2–32 位)', trigger: 'blur' },
|
||||
],
|
||||
role_name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }, { max: 64, message: '最长 64 个字符', trigger: 'blur' }],
|
||||
description: [{ max: 255, message: '最长 255 个字符', trigger: 'blur' }],
|
||||
}
|
||||
async function load(): Promise<void> {
|
||||
const current = ++sequence
|
||||
loading.value = true; failed.value = false
|
||||
try {
|
||||
const result = await getCatalog()
|
||||
if (current === sequence && open.value) permissions.value = result
|
||||
} catch (error) {
|
||||
if (current === sequence && open.value) { failed.value = true; handledError(error) }
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void): Promise<void> {
|
||||
if (saving.value) return
|
||||
if (dirty.value) {
|
||||
try { await ElMessageBox.confirm('角色资料和权限尚未保存,关闭后将放弃这些修改。', '放弃未保存修改', { confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }) }
|
||||
catch { return }
|
||||
}
|
||||
++sequence; done()
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (!canSave.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
await createRole({ ...form, sort_order: form.sort_order ?? 0, permission_codes: selected.value })
|
||||
open.value = false; emit('saved')
|
||||
ElMessage.success('角色及权限已创建,可到用户管理分配角色')
|
||||
} catch (error) { handledError(error) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
watch(open, value => {
|
||||
if (!value) { ++sequence; return }
|
||||
Object.assign(form, { role_code: '', role_name: '', description: '', sort_order: 0 })
|
||||
selected.value = []; permissions.value = []
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="open" title="新增角色与权限" size="min(1120px, 100vw)" class="permission-drawer role-create-drawer" :before-close="beforeClose" destroy-on-close>
|
||||
<div v-loading="loading" class="role-create-editor">
|
||||
<div v-if="failed" class="create-load-error"><el-alert title="权限目录加载失败,请重试后创建角色。" type="error" :closable="false" /><el-button @click="load">重新加载</el-button></div>
|
||||
<template v-else-if="!loading">
|
||||
<section class="role-basics" aria-label="新角色资料">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" :disabled="saving" label-position="top" class="role-basics-grid" @submit.prevent="save">
|
||||
<el-form-item label="角色编码" prop="role_code"><el-input v-model="form.role_code" aria-label="角色编码" placeholder="如 region_editor,创建后不可修改" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="角色名称" prop="role_name"><el-input v-model="form.role_name" aria-label="角色名称" placeholder="如 区域维护员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="说明" prop="description"><el-input v-model="form.description" aria-label="角色说明" placeholder="选填,说明这个角色的职责" maxlength="255" /></el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order"><el-input-number v-model="form.sort_order" aria-label="角色排序" :min="0" :max="9999" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="initial-permissions"><strong>功能权限 <span>已选 {{ selected.length }} 项</span></strong><p>创建时一起保存;未选择权限的角色暂时没有可访问功能。</p></div>
|
||||
</section>
|
||||
<PermissionMatrix v-model="selected" :catalog="permissions" :disabled="saving" />
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="create-save-bar">
|
||||
<el-button :disabled="saving" @click="beforeClose(() => open = false)">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save">创建角色并保存权限</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-create-editor { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||
.role-basics { padding: 16px 24px; border-bottom: 1px solid var(--ys-line); max-height: 38vh; overflow-y: auto; flex: none; }
|
||||
.role-basics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
|
||||
.role-basics-grid :deep(.el-form-item) { min-width: 0; margin-bottom: 16px; }
|
||||
.initial-permissions strong { font-size: 14px; font-weight: 600; }
|
||||
.initial-permissions strong span { margin-left: 12px; color: var(--ys-indigo); font-size: 12px; }
|
||||
.initial-permissions p { margin: 8px 0 0; color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||
.create-save-bar { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 12px; }
|
||||
.create-load-error { padding: 24px; }
|
||||
.create-load-error > .el-button { margin-top: 16px; }
|
||||
@media (max-width: 700px) { .role-basics { padding: 14px 16px; } .role-basics-grid { gap: 0 12px; } }
|
||||
@media (max-width: 420px) { .role-basics-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
99
frontend/src/components/authorization/PermissionDrawer.vue
Normal file
99
frontend/src/components/authorization/PermissionDrawer.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { Lock, User, ArrowRight } from '@element-plus/icons-vue'
|
||||
import PermissionMatrix from './PermissionMatrix.vue'
|
||||
import { usePermissionEditor } from './usePermissionEditor'
|
||||
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const props = defineProps<{ id: number | null; kind: 'user' | 'role'; name?: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const e = reactive(usePermissionEditor(props, open, () => emit('saved')))
|
||||
const title = computed(() => `${props.kind === 'user' ? '分配角色' : e.fixedRole ? '查看角色权限' : '配置角色权限'} · ${props.name ?? ''}`)
|
||||
const previewCodes = computed(() => [...e.effective])
|
||||
const isFixed = computed(() => props.kind === 'role' && e.fixedRole)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="open" :title="title" size="min(1120px, 100vw)" class="permission-drawer" :before-close="e.beforeClose" destroy-on-close>
|
||||
<div v-loading="e.loading" class="permission-editor">
|
||||
<div v-if="e.failed" class="editor-failure"><el-alert type="error" title="权限数据加载失败,请重新加载。" :closable="false" /><el-button @click="e.load">重新加载</el-button></div>
|
||||
<template v-else-if="!e.loading && e.catalog.length">
|
||||
<header class="editor-summary">
|
||||
<div class="authorization-flow"><span :class="{ current: kind === 'role' }">角色配置权限</span><el-icon><ArrowRight /></el-icon><span :class="{ current: kind === 'user' }">用户分配角色</span><el-icon><ArrowRight /></el-icon><span>自动继承权限</span></div>
|
||||
<div class="summary-line"><strong>{{ e.effective.length }}<small> 项{{ kind === 'user' ? '继承权限' : '功能权限' }}</small></strong><span v-if="isFixed" class="fixed-hint"><el-icon><Lock /></el-icon>系统管理员拥有全部权限,仅供查看</span><span v-else class="change-count">新增 <b>{{ e.added.length }}</b> 项<span> · </span>撤销 <b>{{ e.removed.length }}</b> 项</span></div>
|
||||
<p>{{ isFixed ? '系统内置角色。可查看全部功能权限,工资生成后的月份锁定规则继续生效。' : kind === 'user' ? '选择用户承担的角色;需要改变功能范围时,到角色管理统一配置。' : '按业务模块勾选操作。保存后,绑定此角色的用户会同步获得或失去相应权限。' }}</p>
|
||||
</header>
|
||||
|
||||
<section v-if="kind === 'user'" class="role-assignment" aria-label="选择用户角色">
|
||||
<div class="assignment-title"><h3>分配角色</h3><span>可选择多个,权限取并集</span></div>
|
||||
<el-checkbox-group v-model="e.selectedRoles" :disabled="e.saving" @change="e.preview">
|
||||
<div v-for="role in e.roles" :key="role.id" class="role-choice" :class="{ selected: e.selectedRoles.includes(role.role_code) }">
|
||||
<el-checkbox :value="role.role_code" :disabled="(role.status === 0 && !e.selectedRoles.includes(role.role_code)) || (role.role_code === 'admin' && id === e.user.userInfo?.id && e.originalRoles.includes('admin'))" :aria-label="`分配${role.role_name}角色`"><span>{{ role.role_name }}</span><small>{{ role.status === 0 ? '已停用' : `${role.permission_count ?? 0} 项权限` }}</small></el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
<p v-if="!e.selectedRoles.length" class="no-roles">未分配角色时,该账号没有业务功能权限。</p>
|
||||
<p v-else class="role-selection"><el-icon><User /></el-icon>已选:{{ e.selectedRoles.map(e.roleLabel).join('、') }}</p>
|
||||
<el-alert v-if="e.previewFailed" type="error" title="继承权限预览失败,请重试后再保存。" :closable="false"><el-button link @click="e.preview">重试预览</el-button></el-alert>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!isFixed" class="copy-template" aria-label="复制角色权限">
|
||||
<label>从已有角色复制</label><el-select :model-value="undefined" placeholder="可选,复制后仍可调整" aria-label="复制角色权限" :disabled="e.copying || e.saving" @change="e.copyRole"><el-option v-for="role in e.roles.filter(item => item.id !== id && item.status === 1)" :key="role.id" :label="role.role_name" :value="role.id" /></el-select><span>只复制功能权限,不会绑定用户</span>
|
||||
</section>
|
||||
|
||||
<div v-if="e.stale" class="version-conflict"><el-alert type="warning" title="权限已被其他管理员更新,请重新加载后再编辑。" :closable="false"><el-button link @click="e.load">重新加载</el-button></el-alert></div>
|
||||
<PermissionMatrix v-if="kind === 'role'" v-model="e.selectedCodes" :catalog="e.catalog" :readonly="isFixed" :disabled="e.saving || e.copying || e.stale" />
|
||||
<PermissionMatrix v-else v-loading="e.previewing" :model-value="previewCodes" :catalog="e.catalog" readonly inherited :disabled="e.previewing" />
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div v-if="!isFixed && !e.loading && !e.failed" class="permission-save-bar">
|
||||
<div class="save-reason"><label for="authorization-reason">修改原因 <span>必填,记录到操作日志</span></label><el-input id="authorization-reason" v-model="e.reason" :disabled="e.saving" maxlength="500" placeholder="例如:财务经办新增核算权限" aria-label="授权修改原因" /></div>
|
||||
<el-button :disabled="e.saving" @click="e.beforeClose(() => open = false)">取消</el-button><el-button type="primary" :loading="e.saving" :disabled="!e.canSave" @click="e.save">{{ kind === 'user' ? '保存角色分配' : '保存角色权限' }}</el-button>
|
||||
</div>
|
||||
<div v-else class="permission-read-footer"><span>工资生成后的月份锁定与操作留痕继续生效。</span><el-button @click="open = false">关闭</el-button></div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.permission-drawer .el-drawer__body { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.permission-drawer .el-drawer__header { border-bottom: 1px solid var(--ys-line); }
|
||||
.permission-drawer .el-drawer__footer { padding: 16px 24px; border-top: 1px solid var(--ys-line); }
|
||||
</style>
|
||||
<style scoped>
|
||||
.permission-editor { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||
.editor-summary { padding: 20px 24px 16px; border-bottom: 1px solid var(--ys-line); }
|
||||
.authorization-flow { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; color: var(--ys-ink-2); }
|
||||
.authorization-flow .current { color: var(--ys-indigo); font-weight: 600; }
|
||||
.authorization-flow .el-icon { font-size: 11px; }
|
||||
.summary-line { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; margin-top: 18px; }
|
||||
.summary-line > strong { font-size: 26px; font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.summary-line small { font-weight: 400; font-size: 13px; color: var(--ys-ink-2); margin-left: 6px; }
|
||||
.fixed-hint { display: inline-flex; align-items: center; gap: 6px; color: var(--ys-ink-2); font-size: 13px; }
|
||||
.change-count { font-size: 12px; color: var(--ys-ink-2); }
|
||||
.change-count b { color: var(--ys-ink); font-weight: 500; }
|
||||
.editor-summary p { margin: 10px 0 0; font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); }
|
||||
.role-assignment { padding: 16px 24px; border-bottom: 1px solid var(--ys-line); }
|
||||
.assignment-title { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; }
|
||||
.assignment-title h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.assignment-title span, .copy-template > span { color: var(--ys-ink-2); font-size: 12px; }
|
||||
.role-assignment .el-checkbox-group { display: flex; flex-wrap: wrap; gap: 10px; max-height: 155px; overflow-y: auto; }
|
||||
.role-choice { border: 1px solid var(--ys-line); border-radius: 6px; padding: 8px 12px; min-width: 145px; background: var(--ys-paper); }
|
||||
.role-choice.selected { border-color: var(--ys-indigo); background: var(--ys-indigo-wash); }
|
||||
.role-choice :deep(.el-checkbox) { height: auto; margin-right: 0; }
|
||||
.role-choice :deep(.el-checkbox__label) { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
||||
.role-choice small { font-size: 11px; color: var(--ys-ink-2); }
|
||||
.role-selection, .no-roles { display: flex; align-items: center; gap: 6px; margin: 12px 0 0; font-size: 12px; color: var(--ys-ink-2); }
|
||||
.copy-template { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 14px 24px; border-bottom: 1px solid var(--ys-line); font-size: 12px; }
|
||||
.copy-template .el-select { width: 260px; }
|
||||
.permission-save-bar { display: flex; align-items: flex-end; gap: 12px; text-align: left; }
|
||||
.save-reason { flex: 1; min-width: 0; margin-right: 12px; }
|
||||
.save-reason label { display: block; margin-bottom: 8px; font-size: 12px; }
|
||||
.save-reason label span { margin-left: 8px; color: var(--ys-ink-2); }
|
||||
.permission-read-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; text-align: left; }
|
||||
.permission-read-footer > span { color: var(--ys-ink-2); font-size: 12px; }
|
||||
.editor-failure { padding: 24px; }
|
||||
.editor-failure > .el-button { margin-top: 16px; }
|
||||
.version-conflict { padding: 12px 24px; }
|
||||
@media (max-width: 700px) { .editor-summary { padding: 16px; } .role-assignment { padding: 14px 16px; } .permission-save-bar { flex-wrap: wrap; } .save-reason { flex-basis: 100%; margin: 0; } .copy-template { padding: 12px 16px; } .summary-line { gap: 12px; } }
|
||||
</style>
|
||||
115
frontend/src/components/authorization/PermissionMatrix.vue
Normal file
115
frontend/src/components/authorization/PermissionMatrix.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Check, Lock, Search } from '@element-plus/icons-vue'
|
||||
import type { PermissionItem } from '@/api/authorization'
|
||||
|
||||
const selected = defineModel<string[]>({ required: true })
|
||||
const props = withDefaults(defineProps<{ catalog: PermissionItem[]; readonly?: boolean; inherited?: boolean; disabled?: boolean }>(), { readonly: false, inherited: false, disabled: false })
|
||||
const query = ref(''), group = ref('全部功能'), onlyGranted = ref(props.inherited)
|
||||
const codes = computed(() => new Set(selected.value))
|
||||
const modules = computed(() => {
|
||||
const result = new Map<string, { code: string; name: string; group: string; items: PermissionItem[] }>()
|
||||
for (const item of props.catalog) {
|
||||
if (!result.has(item.module)) result.set(item.module, { code: item.module, name: item.module_name, group: item.group, items: [] })
|
||||
result.get(item.module)!.items.push(item)
|
||||
}
|
||||
return [...result.values()]
|
||||
})
|
||||
const groups = computed(() => ['全部功能', ...new Set(props.catalog.map(item => item.group))])
|
||||
const visible = computed(() => modules.value.filter(module => group.value === '全部功能' || module.group === group.value).map(module => {
|
||||
const keyword = query.value.trim().toLowerCase()
|
||||
const matchModule = `${module.group} ${module.name}`.toLowerCase().includes(keyword)
|
||||
const items = module.items.filter(item => (matchModule || item.name.toLowerCase().includes(keyword)) && (!onlyGranted.value || codes.value.has(item.code)))
|
||||
return { ...module, items }
|
||||
}).filter(module => module.items.length))
|
||||
watch(query, value => { if (value.trim()) group.value = '全部功能' })
|
||||
function count(name: string): string {
|
||||
const items = props.catalog.filter(item => name === '全部功能' || item.group === name)
|
||||
return `${items.filter(item => codes.value.has(item.code)).length}/${items.length}`
|
||||
}
|
||||
function toggle(item: PermissionItem, value: boolean): void {
|
||||
if (props.readonly || props.disabled || item.admin_only) return
|
||||
const next = new Set(selected.value)
|
||||
if (value) { next.add(item.code); next.add(item.view_code) }
|
||||
else {
|
||||
next.delete(item.code)
|
||||
if (item.action === 'view') for (const other of props.catalog) if (other.module === item.module) next.delete(other.code)
|
||||
}
|
||||
selected.value = [...next]
|
||||
}
|
||||
function toggleModule(module: typeof modules.value[number], value: boolean): void {
|
||||
const next = new Set(selected.value)
|
||||
if (props.readonly || props.disabled) return
|
||||
// 批量操作作用于完整模块,搜索结果不会让全选偷偷变成部分勾选。
|
||||
for (const item of props.catalog.filter(item => item.module === module.code && !item.admin_only)) {
|
||||
if (value) next.add(item.code)
|
||||
else next.delete(item.code)
|
||||
}
|
||||
selected.value = [...next]
|
||||
}
|
||||
function moduleState(code: string): { all: boolean; partial: boolean } {
|
||||
const items = props.catalog.filter(item => item.module === code)
|
||||
const size = items.filter(item => codes.value.has(item.code)).length
|
||||
return { all: size === items.length, partial: size > 0 && size < items.length }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="permission-matrix">
|
||||
<div class="matrix-toolbar">
|
||||
<el-input v-model="query" :prefix-icon="Search" clearable placeholder="搜索模块或操作" aria-label="搜索权限" />
|
||||
<el-checkbox v-model="onlyGranted">仅看已授权</el-checkbox>
|
||||
<span class="matrix-hint">{{ inherited ? '以下权限自动继承自所选角色' : readonly ? '全部权限固定生效,仅供查看' : '勾选操作会自动包含该模块的查看权限' }}</span>
|
||||
</div>
|
||||
<div class="matrix-layout">
|
||||
<nav class="permission-groups" aria-label="权限业务分组">
|
||||
<button v-for="name in groups" :key="name" type="button" :class="{ active: group === name }" :aria-current="group === name ? 'true' : undefined" @click="group = name"><span>{{ name }}</span><small>{{ count(name) }}</small></button>
|
||||
</nav>
|
||||
<div class="permission-modules" aria-label="模块权限" tabindex="0">
|
||||
<div v-if="!visible.length" class="permission-empty"><strong>{{ onlyGranted && !selected.length ? '尚未授予功能权限' : '没有匹配的权限' }}</strong><p>{{ onlyGranted && !selected.length ? '选择角色或为当前角色勾选需要的功能。' : '试试其他关键词,或关闭“仅看已授权”。' }}</p></div>
|
||||
<section v-for="module in visible" :key="module.code" class="permission-module" :aria-label="`${module.name}权限`">
|
||||
<header><div><h3>{{ module.name }}</h3><span>{{ module.group }}<template v-if="module.items.every(item => item.admin_only)"> · 管理员专用</template></span></div>
|
||||
<el-checkbox v-if="!readonly" :model-value="moduleState(module.code).all" :indeterminate="moduleState(module.code).partial" :disabled="disabled || module.items.every(item => item.admin_only)" :aria-label="`${module.name}模块全选`" @change="toggleModule(module, Boolean($event))">全选</el-checkbox>
|
||||
<span v-else class="read-status"><el-icon><Check v-if="moduleState(module.code).all || moduleState(module.code).partial" /><Lock v-else /></el-icon>{{ inherited ? moduleState(module.code).all ? '已继承' : moduleState(module.code).partial ? '部分继承' : '未授权' : '全部授权' }}</span>
|
||||
</header>
|
||||
<div class="module-actions">
|
||||
<template v-for="item in module.items" :key="item.code">
|
||||
<span v-if="readonly" class="permission-read" :class="{ granted: codes.has(item.code) }"><el-icon><Check v-if="codes.has(item.code)" /><Lock v-else /></el-icon>{{ item.name }}</span>
|
||||
<el-checkbox v-else :model-value="codes.has(item.code)" :disabled="disabled || item.admin_only" :aria-label="`${module.name} ${item.name}`" @change="toggle(item, Boolean($event))">{{ item.name }}</el-checkbox>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-matrix { display: flex; flex-direction: column; min-height: 0; flex: 1; }
|
||||
.matrix-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--ys-line); }
|
||||
.matrix-toolbar .el-input { width: 260px; }
|
||||
.matrix-hint { margin-left: auto; color: var(--ys-ink-2); font-size: 12px; }
|
||||
.matrix-layout { display: grid; grid-template-columns: 180px minmax(0, 1fr); min-height: 0; flex: 1; }
|
||||
.permission-groups { overflow-y: auto; padding: 12px; border-right: 1px solid var(--ys-line); background: var(--ys-mist); }
|
||||
.permission-groups button { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 12px; padding: 13px 12px; margin-bottom: 4px; border: 0; border-radius: 6px; font: inherit; font-size: 13px; background: transparent; color: var(--ys-ink-2); cursor: pointer; text-align: left; }
|
||||
.permission-groups button:hover { background: var(--ys-paper); }
|
||||
.permission-groups button.active { color: var(--ys-indigo); background: var(--ys-paper); font-weight: 600; }
|
||||
.permission-groups small { font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.permission-groups button:focus-visible, .permission-modules:focus-visible { outline: 2px solid var(--ys-indigo); outline-offset: -2px; }
|
||||
.permission-modules { min-width: 0; overflow-y: auto; padding: 0 24px 16px; }
|
||||
.permission-module { padding: 20px 0; border-bottom: 1px solid var(--ys-line); }
|
||||
.permission-module:last-child { border-bottom: 0; }
|
||||
.permission-module header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
h3 { display: inline; margin: 0 12px 0 0; font-size: 14px; font-weight: 600; }
|
||||
.permission-module header > div > span { color: var(--ys-ink-2); font-size: 11px; }
|
||||
.module-actions { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px 16px; }
|
||||
.module-actions :deep(.el-checkbox) { margin: 0; height: 32px; }
|
||||
.module-actions :deep(.el-checkbox__label) { font-size: 13px; }
|
||||
.read-status, .permission-read { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ys-ink-2); }
|
||||
.permission-read { min-height: 32px; }
|
||||
.permission-read.granted .el-icon { color: var(--ys-indigo); }
|
||||
.permission-empty { padding: 60px 16px; text-align: center; color: var(--ys-ink-2); font-size: 13px; }
|
||||
.permission-empty strong { font-weight: 500; color: var(--ys-ink); }
|
||||
.permission-empty p { line-height: 1.8; }
|
||||
@media (max-width: 700px) { .matrix-layout { grid-template-columns: 124px minmax(0, 1fr); } .permission-groups { padding: 8px; } .permission-groups button { padding: 11px 7px; gap: 4px; font-size: 12px; } .matrix-toolbar { padding: 12px 16px; gap: 10px; } .matrix-hint { margin-left: 0; width: 100%; } .permission-modules { padding: 0 14px 12px; } .permission-module header > div > span { display: block; margin-top: 6px; } .module-actions { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); } }
|
||||
</style>
|
||||
89
frontend/src/components/authorization/usePermissionEditor.ts
Normal file
89
frontend/src/components/authorization/usePermissionEditor.ts
Normal file
@ -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<api.PermissionItem[]>([]), roles = ref<RoleItem[]>([])
|
||||
const selectedRoles = ref<string[]>([]), selectedCodes = ref<string[]>([]), originalRoles = ref<string[]>([])
|
||||
const originalCodes = ref<string[]>([]), revision = ref(0), reason = ref(''), result = ref<api.AuthorizationResult | null>(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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 }
|
||||
}
|
||||
78
frontend/src/components/coefficients/CoefficientPanel.vue
Normal file
78
frontend/src/components/coefficients/CoefficientPanel.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/api/request'
|
||||
import service from '@/api/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
interface Row { product_category: string; customer_type: string; stage: string; value: string; slope?: string | null; boundary?: number | null; system_coefficient?: string | null; average_coefficient_360?: string | null; readonly?: boolean }
|
||||
interface Catalog { as_of?:string; rows: Row[]; fingerprint: string; average_rule: string }
|
||||
const user = useUserStore()
|
||||
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` }
|
||||
const asOf = ref(today()), effective = ref(today()), loading = ref(false), saving = ref(false)
|
||||
const data = ref<Catalog>({ rows: [], fingerprint: '', average_rule: '' })
|
||||
const editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([])
|
||||
const baseDate=ref(today())
|
||||
const importVisible = ref(false), fingerprint = ref(''), remark = ref('系数表维护')
|
||||
const importFileName = ref('')
|
||||
const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' }
|
||||
const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' }
|
||||
const stages: Record<string,string> = { first:'首日', linear:'线性递减', after:'超期', fixed:'固定', excluded:'不单笔计提' }
|
||||
async function load() { loading.value = true; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { handledError(e) } finally { loading.value=false } }
|
||||
function edit() { edited.value = data.value.rows.filter(r=>!r.readonly).map(r=>({...r})); fingerprint.value=data.value.fingerprint; baseDate.value=asOf.value; editing.value=true }
|
||||
async function apply(rows: Row[]) {
|
||||
try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return }
|
||||
saving.value=true
|
||||
try { await request({url:'/v1/system/coefficients',method:'put',data:{rows:rows.map(({product_category,customer_type,stage,value,slope,boundary})=>({product_category,customer_type,stage,value,slope,boundary})),effective_date:effective.value,base_date:baseDate.value,fingerprint:fingerprint.value,remark:remark.value}}); ElMessage.success('系数版本已保存'); editing.value=false; importVisible.value=false; await load() } catch(e) { handledError(e) } finally { saving.value=false }
|
||||
}
|
||||
async function download() { try { const response=await service.get('/v1/system/coefficients/export',{params:{as_of:asOf.value},responseType:'blob'}); const url=URL.createObjectURL(response.data); const a=document.createElement('a'); a.href=url;a.download=`绩效系数表_${asOf.value}.xlsx`;a.click();URL.revokeObjectURL(url) } catch(e) {handledError(e)} }
|
||||
async function upload(event: Event) {
|
||||
const input=event.target as HTMLInputElement; const file=input.files?.[0]; if(!file) return
|
||||
importFileName.value = file.name
|
||||
const form=new FormData();form.append('file',file);loading.value=true
|
||||
try { const result=await request<{rows:Row[];errors:{row:number;message:string}[];fingerprint:string;base_date:string}>({url:'/v1/system/coefficients/import-preview',method:'post',data:form}); baseDate.value=result.base_date;pending.value=result.rows;errors.value=result.errors;fingerprint.value=result.fingerprint;importVisible.value=true } catch(e) {handledError(e)} finally {loading.value=false;input.value=''}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="coefficient-panel" aria-label="完整绩效系数表">
|
||||
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段配置计提系数,页面展示与正式核算使用同一版本。</p></div></div>
|
||||
<el-form inline label-position="top">
|
||||
<el-form-item label="查询生效日期"><el-date-picker v-model="asOf" value-format="YYYY-MM-DD" @change="load" /></el-form-item>
|
||||
<el-form-item label="表格操作"><el-button :disabled="!user.can('system:params:update') || asOf!==today()" @click="edit">维护系数表</el-button><el-button :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button></el-form-item>
|
||||
<el-form-item v-if="user.can('system:params:update')" label="Excel 导入">
|
||||
<label class="file-button">
|
||||
<input aria-label="选择系数表 Excel" type="file" accept=".xlsx" @change="upload" />
|
||||
<span>选择文件</span>
|
||||
</label>
|
||||
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="loading" :data="data.rows" border>
|
||||
<el-table-column label="产品类别" width="110"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
|
||||
<el-table-column label="户型" width="110"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
|
||||
<el-table-column label="阶段" width="120"><template #default="{row}">{{stages[row.stage]}}</template></el-table-column>
|
||||
<el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" />
|
||||
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" />
|
||||
<el-table-column prop="boundary" label="截止天数" width="95" />
|
||||
<el-table-column label="系统系数" min-width="150"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column>
|
||||
<el-table-column label="360 天平均系数" min-width="180"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
|
||||
</el-table>
|
||||
<p class="coefficient-note">{{ data.average_rule }}。线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
|
||||
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)">
|
||||
<el-table :data="edited" border><el-table-column label="产品/户型/阶段" min-width="210"><template #default="{row}">{{categories[row.product_category]}} · {{customers[row.customer_type]}} · {{stages[row.stage]}}</template></el-table-column><el-table-column label="给付标准/起点(‰)" min-width="150"><template #default="{row}"><el-input v-model="row.value" aria-label="给付标准" /></template></el-table-column><el-table-column label="日斜率(‰)" min-width="140"><template #default="{row}"><el-input v-if="row.stage==='linear'" v-model="row.slope" aria-label="日斜率" /></template></el-table-column><el-table-column label="截止天数" min-width="130"><template #default="{row}"><el-input-number v-if="row.stage==='linear'" v-model="row.boundary" :min="1" :max="3660" :controls="false" aria-label="截止天数" /></template></el-table-column></el-table>
|
||||
<el-form label-position="top"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)"><p>工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p>
|
||||
<el-alert v-if="errors.length" type="error" :closable="false" title="校验未通过,整批不能写入" /><el-table v-if="errors.length" :data="errors"><el-table-column prop="row" label="Excel 行" width="100" /><el-table-column prop="message" label="问题" /></el-table>
|
||||
<el-table :data="pending"><el-table-column v-for="key in ['product_category','customer_type','stage','value','slope','boundary']" :key="key" :prop="key" :label="({product_category:'产品类别',customer_type:'户型',stage:'阶段',value:'给付标准(‰)',slope:'日斜率',boundary:'截止天数'} as Record<string,string>)[key]" min-width="120" /></el-table>
|
||||
<el-form label-position="top"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.coefficient-panel{margin-block:24px;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;align-items:start;gap:16px}.panel-head h2{margin:0;font-size:18px}.panel-head p,.coefficient-note{color:var(--el-text-color-secondary);font-size:13px;line-height:1.7}.coefficient-panel .el-form{margin-top:16px}.file-button{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border:1px solid var(--ys-indigo);border-radius:6px;color:var(--ys-indigo);background:var(--ys-paper);font-size:13px;line-height:1.5;cursor:pointer;transition:background-color 120ms var(--ys-ease), color 120ms var(--ys-ease)}.file-button:hover{background:var(--ys-indigo-wash)}.file-button:focus-within{outline:2px solid var(--ys-indigo);outline-offset:2px}.file-button input{position:absolute;width:1px;height:1px;margin:-1px;clip:rect(0 0 0 0);overflow:hidden}.file-name{color:var(--ys-ink-2);font-size:12px;line-height:1.6}@media(max-width:768px){.coefficient-panel{padding:12px}.panel-head{flex-direction:column}}
|
||||
</style>
|
||||
32
frontend/src/components/feedback/PayrollAccessNotice.vue
Normal file
32
frontend/src/components/feedback/PayrollAccessNotice.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { routePermissions } from '@/router/permissions'
|
||||
|
||||
const props = withDefaults(defineProps<{ action?: string; permission?: string }>(), { action: '修改业务资料' })
|
||||
const user = useUserStore()
|
||||
const route = useRoute()
|
||||
const permission = computed(() => props.permission ?? (
|
||||
route.path === '/calc/ledger' ? 'payroll:calculate' : route.path === '/settlements' ? 'settlements:generate'
|
||||
: routePermissions[route.path]?.replace(/:view$/, ':update') ?? 'payroll:calculate'
|
||||
))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-alert
|
||||
v-if="!user.can(permission)"
|
||||
class="payroll-access-notice"
|
||||
:title="`当前账号可查看资料;${action}:${user.permissionReason(permission)}。`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.payroll-access-notice {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@ -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) {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import AppMenu from '@/components/layout/AppMenu.vue'
|
||||
import { firstAccessiblePath } from '@/router/access'
|
||||
import { canAccessPath, firstAccessiblePath } from '@/router/access'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
||||
@ -13,6 +13,29 @@ const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
||||
let refreshingAuthorization = false
|
||||
async function refreshAuthorization(): Promise<void> {
|
||||
if (!userStore.token || document.visibilityState !== 'visible' || refreshingAuthorization) return
|
||||
refreshingAuthorization = true
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
if (route.path !== '/no-access' && !canAccessPath(router, route.fullPath, userStore.roles)) await router.replace(firstAccessiblePath(router, userStore.roles))
|
||||
} catch { /* 请求拦截器处理失效或连接错误,下次激活时再检查。 */ }
|
||||
finally { refreshingAuthorization = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
refreshTimer = setInterval(() => { void refreshAuthorization() }, 30000)
|
||||
window.addEventListener('focus', refreshAuthorization)
|
||||
window.addEventListener('authorization-changed', refreshAuthorization)
|
||||
window.addEventListener('authorization-refresh', refreshAuthorization)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
window.removeEventListener('focus', refreshAuthorization)
|
||||
window.removeEventListener('authorization-changed', refreshAuthorization)
|
||||
window.removeEventListener('authorization-refresh', refreshAuthorization)
|
||||
})
|
||||
|
||||
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
|
||||
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
|
||||
@ -33,6 +56,7 @@ watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
drawerVisible.value = false
|
||||
void refreshAuthorization()
|
||||
},
|
||||
)
|
||||
|
||||
@ -92,7 +116,13 @@ async function handleLogout(): Promise<void> {
|
||||
</el-header>
|
||||
|
||||
<el-main class="app-main">
|
||||
<router-view />
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition name="ys-page" mode="out-in">
|
||||
<div :key="route.path" class="app-page-view">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</Transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
@ -122,7 +152,7 @@ async function handleLogout(): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
|
||||
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
|
||||
background: var(--ys-aside-1);
|
||||
border-right: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
@ -170,12 +200,14 @@ async function handleLogout(): Promise<void> {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fdfdfe);
|
||||
background: var(--ys-paper);
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.app-header__left {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@ -221,11 +253,17 @@ async function handleLogout(): Promise<void> {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-indigo);
|
||||
background: linear-gradient(180deg, #f1f4fb, var(--ys-indigo-wash));
|
||||
background: var(--ys-indigo-wash);
|
||||
border: 1px solid rgba(46, 75, 168, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.app-header__user :deep(.el-tag--info) {
|
||||
color: var(--ys-ink);
|
||||
background-color: var(--ys-aside-1);
|
||||
border-color: var(--ys-line);
|
||||
}
|
||||
|
||||
.app-header__username {
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink);
|
||||
@ -245,9 +283,27 @@ async function handleLogout(): Promise<void> {
|
||||
*/
|
||||
padding: 22px;
|
||||
background-color: var(--ys-mist);
|
||||
background-image:
|
||||
radial-gradient(900px 320px at 50% -60px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 70%),
|
||||
radial-gradient(1200px 420px at 50% -140px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 75%);
|
||||
}
|
||||
|
||||
.app-page-view {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ys-page-enter-active {
|
||||
transition: opacity 160ms ease-out, transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.ys-page-leave-active {
|
||||
transition: opacity 80ms ease-in;
|
||||
}
|
||||
|
||||
.ys-page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.ys-page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -260,14 +316,73 @@ async function handleLogout(): Promise<void> {
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-header {
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.app-header__left {
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header__menu-toggle {
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.app-header__left :deep(.el-breadcrumb) {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__left :deep(.el-breadcrumb__item:not(:last-child)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__left :deep(.el-breadcrumb__item:last-child),
|
||||
.app-header__left :deep(.el-breadcrumb__inner) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__user {
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__user :deep(.el-tag),
|
||||
.app-header__username,
|
||||
.app-header__divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__user :deep(.el-button) {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-header__menu-toggle {
|
||||
.app-header__menu-toggle,
|
||||
.ys-page-enter-active,
|
||||
.ys-page-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ys-page-enter-from {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -275,6 +390,6 @@ async function handleLogout(): Promise<void> {
|
||||
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
|
||||
.app-drawer .el-drawer__body {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
|
||||
background: var(--ys-aside-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Histogram,
|
||||
House,
|
||||
Management,
|
||||
Monitor,
|
||||
Money,
|
||||
Notebook,
|
||||
OfficeBuilding,
|
||||
@ -48,6 +49,7 @@ const ICONS = {
|
||||
settings: OfficeBuilding,
|
||||
approvals: Stamp,
|
||||
logs: Tickets,
|
||||
monitor: Monitor,
|
||||
tplus: Refresh,
|
||||
personnel: Postcard,
|
||||
products: Box,
|
||||
|
||||
30
frontend/src/components/payroll/ProbationPanel.vue
Normal file
30
frontend/src/components/payroll/ProbationPanel.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/api/request'
|
||||
import service from '@/api/request'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { useUserStore } from '@/store/user'
|
||||
interface Detail {ledger_id:number; business_no:string; month:string; receipt_date:string; accrued:string; eligible:string}
|
||||
interface Balance {fingerprint:string;personnel_id:number;personnel_name:string;year:string;accrued:string;eligible:string;threshold:string;entitlement:string;paid:string;delta:string;remaining:string;details:Detail[]}
|
||||
const user=useUserStore(),month=ref(`${new Date().getFullYear()}-${String(new Date().getMonth()+1).padStart(2,'0')}`),loading=ref(false),rows=ref<Balance[]>([]),rule=ref(''),detail=ref<Balance|null>(null)
|
||||
const visible=ref(false),keyword=ref(''),year=ref('')
|
||||
async function load(){loading.value=true;try{const data=await request<{rows:Balance[];rule:string}>({url:'/v1/payroll/probation',params:{month:month.value}});rows.value=data.rows;rule.value=data.rule}catch(e){handledError(e)}finally{loading.value=false}}
|
||||
async function download(){try{const response=await service.get('/v1/payroll/probation/export',{params:{month:month.value},responseType:'blob'});const url=URL.createObjectURL(response.data);const a=document.createElement('a');a.href=url;a.download=`试用期缓发与年终结清_${month.value}.xlsx`;a.click();URL.revokeObjectURL(url)}catch(e){handledError(e)}}
|
||||
async function adjust(row:Balance){try{const {value}=await ElMessageBox.prompt('将按本月年度余额生成当月补发/扣回调整单,请填写原因。','生成结清调整单',{inputValidator:(v:string)=>v.trim().length>0||'请填写原因'});await request({url:'/v1/payroll/probation/adjustments',method:'post',data:{personnel_id:row.personnel_id,year:row.year,fingerprint:row.fingerprint,reason:value.trim()}});ElMessage.success('结清调整单已生成');await load()}catch(e){if(e!=='cancel'&&e!=='close')handledError(e)}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="probation-panel" aria-label="试用期缓发与年度结清">
|
||||
<div class="panel-head"><div><h2>试用期缓发与年度结清</h2><p>{{rule}}</p></div><el-button :disabled="!user.can('reports:personal:export')" @click="download">导出年度余额及明细</el-button></div>
|
||||
<el-form inline label-position="top"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><el-form-item label="查询"><el-button :loading="loading" @click="load">刷新结清预览</el-button></el-form-item></el-form>
|
||||
<el-table v-loading="loading" :data="rows.filter(r=>(!keyword||r.personnel_name.includes(keyword))&&(!year||r.year===year))" border empty-text="该月没有试用期缓发记录">
|
||||
<el-table-column prop="personnel_name" label="人员" width="110" /><el-table-column prop="year" label="原年度" width="90" />
|
||||
<el-table-column v-for="[key,label] in [['accrued','试用期累计计提'],['eligible','符合支付条件'],['threshold','年度门槛'],['entitlement','超额权益'],['paid','已补发'],['reserved','待审核占用'],['delta','本月补发/扣回'],['remaining','剩余权益']]" :key="key" :prop="key" :label="label" min-width="140" align="right" />
|
||||
<el-table-column label="依据/结清" min-width="210"><template #default="{row}"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<p class="panel-note">这里只查询及预览。实际补发随月结在同一事务中固化;生成失败不占余额,已生成工资保留历史结果。</p>
|
||||
<el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额" /><el-table-column prop="eligible" label="应付变化" /></el-table></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.probation-panel{margin:24px 0;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;gap:16px}.panel-head h2{font-size:18px;margin:0}.panel-head p,.panel-note{font-size:13px;color:var(--el-text-color-secondary);line-height:1.7}.el-form{margin-top:16px}@media(max-width:768px){.probation-panel{padding:12px}.panel-head{flex-direction:column}}</style>
|
||||
27
frontend/src/components/products/ProductExtractDialog.vue
Normal file
27
frontend/src/components/products/ProductExtractDialog.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/api/request'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { useUserStore } from '@/store/user'
|
||||
interface Candidate {id:number;business_no:string;product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string}
|
||||
interface PlanRow {product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string;action:string;reason:string|null;target_id:number|null}
|
||||
const emit=defineEmits<{done:[]}>(),user=useUserStore(),visible=ref(false),loading=ref(false),keyword=ref(''),page=ref(1),more=ref(false),rows=ref<Candidate[]>([]),selected=ref<Candidate[]>([]),result=ref<{rows:PlanRow[];fingerprint:string}|null>(null),executed=ref(false)
|
||||
async function load(){loading.value=true;result.value=null;try{const data=await request<{rows:Candidate[];has_more:boolean}>({url:'/v1/base/products/extract/candidates',params:{keyword:keyword.value,page_num:page.value}});rows.value=data.rows;more.value=data.has_more}catch(e){handledError(e)}finally{loading.value=false}}
|
||||
async function open(){visible.value=true;page.value=1;selected.value=[];await load()}
|
||||
async function preview(){loading.value=true;try{result.value=await request({url:'/v1/base/products/extract/preview',method:'post',data:{item_ids:selected.value.map(r=>r.id)}});executed.value=false}catch(e){handledError(e)}finally{loading.value=false}}
|
||||
async function apply(){if(!result.value)return;try{await ElMessageBox.confirm('新建缺少的产品档案,并建立业务明细关联。单位或分类冲突行保留待处理。','确认提取产品')}catch{return}loading.value=true;try{result.value=await request({url:'/v1/base/products/extract',method:'post',data:{item_ids:selected.value.map(r=>r.id),fingerprint:result.value.fingerprint}});executed.value=true;ElMessage.success('提取完成,请查看逐项结果');emit('done')}catch(e){handledError(e)}finally{loading.value=false}}
|
||||
defineExpose({open})
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)">
|
||||
<el-form inline><el-form-item label="名称/业务编号"><el-input v-model="keyword" clearable @keyup.enter="page=1;load()" /></el-form-item><el-form-item><el-button @click="page=1;load()">查询</el-button></el-form-item></el-form>
|
||||
<p>先在业务管理录入产品明细,或将 T+ 数据正式导入业务,再勾选未关联产品档案的明细,点击“预览所选”并确认提取。T+ 原始采集成功不会自动建产品档案。</p>
|
||||
<p>同名同规格合并,数量与单价取所选最新明细;已有档案只建立关联,不覆盖人工信息。</p>
|
||||
<el-table v-loading="loading" :data="rows" row-key="id" empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。" @selection-change="(values:Candidate[])=>{selected=values;result=null}"><el-table-column type="selection" width="50" /><el-table-column prop="business_no" label="业务编号" min-width="155" /><el-table-column prop="product_name" label="产品名称" min-width="150" /><el-table-column prop="spec_model" label="规格" min-width="110" /><el-table-column prop="unit" label="单位" width="80" /><el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column><el-table-column prop="quantity" label="数量" width="100" /><el-table-column prop="sale_price" label="成交单价" width="120" /></el-table>
|
||||
<div class="extract-actions"><el-button :disabled="page<=1" @click="page--;load()">上一页</el-button><span>第 {{page}} 页</span><el-button :disabled="!more" @click="page++;load()">下一页</el-button><el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button></div>
|
||||
<el-table v-if="result" :data="result.rows" border><el-table-column prop="product_name" label="产品" /><el-table-column prop="spec_model" label="规格" /><el-table-column label="处理结果" width="130"><template #default="{row}">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</template></el-table-column><el-table-column prop="reason" label="说明" min-width="210" /><el-table-column prop="target_id" label="产品 ID" width="100" /></el-table>
|
||||
<template #footer><el-button @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!result||executed||!user.can('base:products:create')" :loading="loading" @click="apply">确认执行提取</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>.extract-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end;margin:16px 0}p{font-size:13px;line-height:1.7;color:var(--el-text-color-secondary)}@media(max-width:600px){.extract-actions{flex-wrap:wrap}}</style>
|
||||
@ -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')
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
16
frontend/src/router/permissions.ts
Normal file
16
frontend/src/router/permissions.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export const routePermissions: Record<string, string> = {
|
||||
'/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<string, string> = {
|
||||
'personal-detail': 'reports:personal', 'department-summary': 'reports:department',
|
||||
'business-stats': 'reports:business', 'payment-stats': 'reports:payments',
|
||||
}
|
||||
@ -17,6 +17,12 @@ export const useUserStore = defineStore('user', () => {
|
||||
const canWritePayroll = computed(() => userInfo.value?.can_write_payroll === true)
|
||||
const roles = computed<string[]>(() => userInfo.value?.roles ?? [])
|
||||
const displayName = computed<string>(() => 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<void> {
|
||||
@ -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 }
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { options } from '@/api/metadata'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
createRatingGrade,
|
||||
deleteRatingGrade,
|
||||
listRatingGrades,
|
||||
updateRatingGrade,
|
||||
type RatingGradeItem,
|
||||
type RatingGradeParams,
|
||||
@ -15,6 +15,8 @@ import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { resolveGrade } from '@/utils/rating'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { useUserStore } from '@/store/user'
|
||||
const permissionUser = useUserStore()
|
||||
|
||||
/**
|
||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
|
||||
@ -95,7 +97,7 @@ const gradesLoading = ref(false)
|
||||
async function loadGrades(): Promise<void> {
|
||||
gradesLoading.value = true
|
||||
try {
|
||||
grades.value = await listRatingGrades()
|
||||
grades.value = await options<RatingGradeItem>('rating_grades')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
@ -330,7 +332,7 @@ function removeGrade(row: RatingGradeItem): void {
|
||||
<template #footer>
|
||||
<el-button @click="resetScores">重置</el-button>
|
||||
<el-button @click="openDrawer">等级映射设置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存评分</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave" :disabled="!permissionUser.can('base:customers:rating')" :title="permissionUser.permissionReason('base:customers:rating')">保存评分</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@ -340,7 +342,7 @@ function removeGrade(row: RatingGradeItem): void {
|
||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||
</div>
|
||||
<div class="drawer-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||
<el-button type="primary" size="small" @click="openCreate" :disabled="!permissionUser.can('business:ratings:create')" :title="permissionUser.permissionReason('business:ratings:create')">新增等级</el-button>
|
||||
</div>
|
||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||
@ -350,9 +352,9 @@ function removeGrade(row: RatingGradeItem): void {
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!permissionUser.can('business:ratings:update')" :title="permissionUser.permissionReason('business:ratings:update')">编辑</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeGrade(row)" :disabled="!permissionUser.can('business:ratings:delete')" :title="permissionUser.permissionReason('business:ratings:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -403,11 +405,11 @@ function removeGrade(row: RatingGradeItem): void {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="form-tip-block">
|
||||
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
|
||||
分值范围为 0~100,下限不能高于上限;各区间不能重叠,名称不能重复。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="saveGrade" :disabled="!permissionUser.can(editingId === null ? 'business:ratings:create' : 'business:ratings:update')">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import { optionPage, options } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
@ -8,7 +12,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import * as customerApi from '@/api/customers'
|
||||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||||
import * as personnelApi from '@/api/personnel'
|
||||
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
|
||||
import { type RatingGradeItem } from '@/api/ratings'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
@ -121,7 +125,7 @@ const grades = ref<RatingGradeItem[]>([])
|
||||
|
||||
async function loadGrades(): Promise<void> {
|
||||
try {
|
||||
grades.value = await listRatingGrades()
|
||||
grades.value = await options<RatingGradeItem>('rating_grades')
|
||||
} catch (error) {
|
||||
// 等级配置拉取失败不该挡住客户列表:徽标退化为只显示分数
|
||||
handledError(error)
|
||||
@ -162,7 +166,7 @@ const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<Per
|
||||
async () => {
|
||||
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||
// 极端场景由后端对归属合法性的校验兜底
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
const data = await optionPage<Awaited<ReturnType<typeof personnelApi.listPersonnel>>['list'][number]>('personnel')
|
||||
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
},
|
||||
)
|
||||
@ -477,7 +481,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增客户</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:customers:create')" :title="permissionUser.permissionReason('base:customers:create')">新增客户</el-button>
|
||||
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
|
||||
</div>
|
||||
|
||||
@ -561,13 +565,14 @@ onMounted(() => {
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!permissionUser.can('base:customers:update')" :title="permissionUser.permissionReason('base:customers:update')">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)" :disabled="!permissionUser.can('base:customers:ownership')" :title="permissionUser.permissionReason('base:customers:ownership')">
|
||||
归属变更
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('base:customers:delete')" :title="permissionUser.permissionReason('base:customers:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -591,7 +596,7 @@ onMounted(() => {
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form ref="formRef" :disabled="!permissionUser.can(isEdit ? 'base:customers:update' : 'base:customers:create')" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="公司名称" prop="company_name">
|
||||
<el-input v-model="form.company_name" maxlength="128" placeholder="与负责人构成组合唯一键" />
|
||||
</el-form-item>
|
||||
@ -642,7 +647,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:customers:update' : 'base:customers:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:customers:update' : 'base:customers:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@ -686,7 +691,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit" :disabled="!permissionUser.can('base:customers:ownership')" :title="permissionUser.permissionReason('base:customers:ownership')">
|
||||
提交变更
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import * as personnelApi from '@/api/personnel'
|
||||
@ -66,6 +70,7 @@ interface SelectOption {
|
||||
id: number
|
||||
name: string
|
||||
status: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
|
||||
@ -78,9 +83,7 @@ const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<Selec
|
||||
|
||||
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
|
||||
async () => {
|
||||
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||
// 超出规模的极端场景由后端对经理合法性的校验兜底
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
const data = await optionPage<Awaited<ReturnType<typeof personnelApi.listPersonnel>>['list'][number]>('managers')
|
||||
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
},
|
||||
)
|
||||
@ -93,6 +96,7 @@ const editingId = ref<number | null>(null)
|
||||
const isEdit = computed(() => editingId.value !== null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const salaryHistory=ref<Record<string,string|null>[]>([])
|
||||
|
||||
const form = reactive({
|
||||
personnel_name: '',
|
||||
@ -100,6 +104,10 @@ const form = reactive({
|
||||
manager_id: undefined as number | undefined,
|
||||
phone: '',
|
||||
hire_date: '',
|
||||
probation_start_date: '',
|
||||
regularization_date: '',
|
||||
resignation_date: '',
|
||||
salary_effective_date: '',
|
||||
is_probation: 0,
|
||||
salary_standard: '',
|
||||
remark: '',
|
||||
@ -124,9 +132,14 @@ const formRoleOptions = computed(() => {
|
||||
})
|
||||
|
||||
/** 经理下拉:排除编辑中的人员自己(后端校验「经理不能是自己」) */
|
||||
const formManagerOptions = computed(() =>
|
||||
managerOptions.value.filter((m) => m.id !== editingId.value),
|
||||
)
|
||||
const formManagerOptions = computed(() => {
|
||||
const options = managerOptions.value.filter((m) => m.id !== editingId.value)
|
||||
const row = editingRow.value
|
||||
if (row?.manager_id && !options.some(item => item.id === row.manager_id)) {
|
||||
options.unshift({ id: row.manager_id, name: `${row.manager_name ?? '原直属经理'}(历史参考)`, status: 0, disabled: true })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
personnel_name: [
|
||||
@ -142,6 +155,7 @@ const rules = computed<FormRules>(() => ({
|
||||
}))
|
||||
|
||||
function openCreate(): void {
|
||||
salaryHistory.value=[]
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
personnel_name: '',
|
||||
@ -149,6 +163,10 @@ function openCreate(): void {
|
||||
manager_id: undefined,
|
||||
phone: '',
|
||||
hire_date: '',
|
||||
probation_start_date: '',
|
||||
regularization_date: '',
|
||||
resignation_date: '',
|
||||
salary_effective_date: '',
|
||||
is_probation: 0,
|
||||
salary_standard: '',
|
||||
remark: '',
|
||||
@ -158,6 +176,8 @@ function openCreate(): void {
|
||||
}
|
||||
|
||||
function openEdit(row: PersonnelItem): void {
|
||||
salaryHistory.value=[]
|
||||
void personnelApi.getPersonnelDetail(row.id).then(detail=>{if(editingId.value===row.id)salaryHistory.value=detail.salary_history_json ?? []}).catch(handledError)
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
personnel_name: row.personnel_name,
|
||||
@ -165,6 +185,10 @@ function openEdit(row: PersonnelItem): void {
|
||||
manager_id: row.manager_id ?? undefined,
|
||||
phone: row.phone ?? '',
|
||||
hire_date: row.hire_date ?? '',
|
||||
probation_start_date: row.probation_start_date ?? '',
|
||||
regularization_date: row.regularization_date ?? '',
|
||||
resignation_date: row.resignation_date ?? '',
|
||||
salary_effective_date: '',
|
||||
is_probation: row.is_probation,
|
||||
salary_standard: row.salary_standard ?? '',
|
||||
remark: row.remark ?? '',
|
||||
@ -184,6 +208,10 @@ async function handleSubmit(): Promise<void> {
|
||||
manager_id: form.manager_id ?? null,
|
||||
phone: form.phone.trim() || null,
|
||||
hire_date: form.hire_date || null,
|
||||
probation_start_date: form.probation_start_date || null,
|
||||
regularization_date: form.regularization_date || null,
|
||||
resignation_date: form.resignation_date || null,
|
||||
salary_effective_date: form.salary_effective_date || null,
|
||||
is_probation: form.is_probation,
|
||||
salary_standard: form.salary_standard.trim() || null,
|
||||
remark: form.remark.trim() || null,
|
||||
@ -333,6 +361,9 @@ async function savePlans(): Promise<void> {
|
||||
async function handleToggleStatus(row: PersonnelItem): Promise<void> {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
const action = next === 1 ? '设为在职' : '设为离职'
|
||||
const now=new Date();const initial=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||||
let effectiveDate=initial
|
||||
try{const result=await ElMessageBox.prompt('填写任职状态生效日期(YYYY-MM-DD);历史工资保留原版本。',action,{inputValue:initial,inputPattern:/^20\d{2}-\d{2}-\d{2}$/,inputErrorMessage:'请填写有效日期'});effectiveDate=result.value}catch{return}
|
||||
await confirmAndRun({
|
||||
confirmText:
|
||||
next === 0
|
||||
@ -341,7 +372,7 @@ async function handleToggleStatus(row: PersonnelItem): Promise<void> {
|
||||
title: '状态确认',
|
||||
confirmButtonText: action,
|
||||
successText: `已${action}`,
|
||||
run: () => personnelApi.setPersonnelStatus(row.id, next),
|
||||
run: () => personnelApi.setPersonnelStatus(row.id, next, effectiveDate),
|
||||
after: load,
|
||||
})
|
||||
}
|
||||
@ -424,7 +455,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增人员</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:personnel:create')" :title="permissionUser.permissionReason('base:personnel:create')">新增人员</el-button>
|
||||
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
|
||||
</div>
|
||||
|
||||
@ -471,15 +502,16 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="270" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!permissionUser.can('base:personnel:update')" :title="permissionUser.permissionReason('base:personnel:update')">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openSubTables(row)">
|
||||
区域与计划
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)" :disabled="!permissionUser.can('base:personnel:status')" :title="permissionUser.permissionReason('base:personnel:status')">
|
||||
{{ row.status === 1 ? '设为离职' : '设为在职' }}
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('base:personnel:delete')" :title="permissionUser.permissionReason('base:personnel:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -500,10 +532,10 @@ onMounted(() => {
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑人员' : '新增人员'"
|
||||
width="560px"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:personnel:update' : 'base:personnel:create')" :model="form" :rules="rules" label-width="132px">
|
||||
<el-form-item label="姓名" prop="personnel_name">
|
||||
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
|
||||
</el-form-item>
|
||||
@ -525,7 +557,7 @@ onMounted(() => {
|
||||
<el-form-item label="直属经理" prop="manager_id">
|
||||
<el-select
|
||||
v-model="form.manager_id"
|
||||
placeholder="不选择=无直属经理"
|
||||
placeholder="选填,仅列在职经理岗位人员"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
@ -535,8 +567,10 @@ onMounted(() => {
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-hint">无固定直属经理可留空;此处仅作档案参考,实际绩效按每笔业务的分管经理核算。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
|
||||
@ -550,10 +584,14 @@ onMounted(() => {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="试用期开始日"><el-date-picker v-model="form.probation_start_date" value-format="YYYY-MM-DD" placeholder="含当日" /></el-form-item>
|
||||
<el-form-item label="转正生效日"><el-date-picker v-model="form.regularization_date" value-format="YYYY-MM-DD" placeholder="当日按正式人员计算" /></el-form-item>
|
||||
<el-form-item label="试用期" prop="is_probation">
|
||||
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
|
||||
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="离职生效日"><el-date-picker v-model="form.resignation_date" value-format="YYYY-MM-DD" placeholder="当日起不计基本工资" /></el-form-item>
|
||||
<el-form-item label="工资资料生效日"><el-date-picker v-model="form.salary_effective_date" value-format="YYYY-MM-DD" placeholder="选择生效日期" /><div class="form-hint">新增默认入职日期;编辑默认今天,历史资料保留。</div></el-form-item>
|
||||
<el-form-item label="工资标准" prop="salary_standard">
|
||||
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
|
||||
</el-form-item>
|
||||
@ -561,9 +599,10 @@ onMounted(() => {
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:personnel:update' : 'base:personnel:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:personnel:update' : 'base:personnel:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@ -574,13 +613,13 @@ onMounted(() => {
|
||||
<div class="sub-section-head">
|
||||
<span class="sub-section-title">辐射区域</span>
|
||||
<span>
|
||||
<el-button size="small" @click="addRegionRow" :disabled="!payrollUser.canWritePayroll">添加区域</el-button>
|
||||
<el-button size="small" @click="addRegionRow" :disabled="!permissionUser.can('base:personnel:regions')" :title="permissionUser.permissionReason('base:personnel:regions')">添加区域</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="regionSaving"
|
||||
@click="saveRegions"
|
||||
:disabled="!payrollUser.canWritePayroll">
|
||||
:disabled="!permissionUser.can('base:personnel:regions')" :title="permissionUser.permissionReason('base:personnel:regions')">
|
||||
保存区域
|
||||
</el-button>
|
||||
</span>
|
||||
@ -589,21 +628,21 @@ onMounted(() => {
|
||||
:data="regionRows"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="还没有辐射区域 —— 点「添加区域」加一条"
|
||||
:empty-text="permissionUser.can('base:personnel:regions') ? '还没有辐射区域,可添加一条' : '暂无辐射区域;当前账号仅可查看'"
|
||||
>
|
||||
<el-table-column label="区域名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
|
||||
<el-input :disabled="!permissionUser.can('base:personnel:regions')" v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" maxlength="255" placeholder="选填" />
|
||||
<el-input :disabled="!permissionUser.can('base:personnel:regions')" v-model="row.remark" maxlength="255" placeholder="选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeRegionRow($index)" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button link type="danger" size="small" @click="removeRegionRow($index)" :disabled="!permissionUser.can('base:personnel:regions')" :title="permissionUser.permissionReason('base:personnel:regions')">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
@ -615,8 +654,8 @@ onMounted(() => {
|
||||
<div class="sub-section-head">
|
||||
<span class="sub-section-title">拓展计划</span>
|
||||
<span>
|
||||
<el-button size="small" @click="addPlanRow" :disabled="!payrollUser.canWritePayroll">添加计划</el-button>
|
||||
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button size="small" @click="addPlanRow" :disabled="!permissionUser.can('base:personnel:plans')" :title="permissionUser.permissionReason('base:personnel:plans')">添加计划</el-button>
|
||||
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans" :disabled="!permissionUser.can('base:personnel:plans')" :title="permissionUser.permissionReason('base:personnel:plans')">
|
||||
保存计划
|
||||
</el-button>
|
||||
</span>
|
||||
@ -625,26 +664,26 @@ onMounted(() => {
|
||||
:data="planRows"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="还没有拓展计划 —— 点「添加计划」加一条"
|
||||
:empty-text="permissionUser.can('base:personnel:plans') ? '还没有拓展计划,可添加一条' : '暂无拓展计划;当前账号仅可查看'"
|
||||
>
|
||||
<el-table-column label="期间" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
|
||||
<el-input :disabled="!permissionUser.can('base:personnel:plans')" v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划内容" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.plan_content" maxlength="500" placeholder="计划内容" />
|
||||
<el-input :disabled="!permissionUser.can('base:personnel:plans')" v-model="row.plan_content" maxlength="500" placeholder="计划内容" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指标说明" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.target_desc" maxlength="255" placeholder="选填" />
|
||||
<el-input :disabled="!permissionUser.can('base:personnel:plans')" v-model="row.target_desc" maxlength="255" placeholder="选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removePlanRow($index)" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button link type="danger" size="small" @click="removePlanRow($index)" :disabled="!permissionUser.can('base:personnel:plans')" :title="permissionUser.permissionReason('base:personnel:plans')">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import ProductExtractDialog from '@/components/products/ProductExtractDialog.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
@ -58,6 +62,7 @@ const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
const isEdit = computed(() => editingId.value !== null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const extractDialog = ref<InstanceType<typeof ProductExtractDialog>>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
@ -179,6 +184,10 @@ async function handleDelete(row: ProductItem): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreSource(row: ProductItem): Promise<void> {
|
||||
await confirmAndRun({confirmText:`将产品「${row.product_name}」的数量和单价恢复为最近有效发货来源,并取消人工保护。`,title:'恢复来源值',confirmButtonText:'恢复源值',successText:'已恢复来源值',run:()=>productApi.restoreProductSource(row.id),after:load})
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
@ -187,6 +196,8 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirements-surface">
|
||||
<ProductExtractDialog ref="extractDialog" @done="load" />
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">产品管理</h1>
|
||||
@ -245,7 +256,8 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增产品</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:products:create')" :title="permissionUser.permissionReason('base:products:create')">新增产品</el-button>
|
||||
<el-button :disabled="!permissionUser.can('base:products:create')" @click="extractDialog?.open()">从业务提取产品</el-button>
|
||||
<span class="toolbar-note">常规下架用「停用」,删除是物理删除、不可恢复</span>
|
||||
</div>
|
||||
|
||||
@ -264,13 +276,14 @@ onMounted(() => {
|
||||
<el-table-column prop="unit" label="单位" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.unit ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="100" align="right">
|
||||
<el-table-column label="来源/人工保护" min-width="205"><template #default="{row}"><div>{{ row.source_trade_json?.date ?? '人工维护' }}</div><div v-if="row.source_trade_json?.source_no" class="cell-muted">{{ row.source_trade_json.source_no }}</div><div v-else class="cell-muted">暂无有效发货来源</div><el-tag v-if="row.quantity_price_manual" type="warning" size="small">人工值保护</el-tag><div v-if="row.quantity_price_manual && row.source_trade_json">源值:{{row.source_trade_json.quantity}} / {{row.source_trade_json.price}}<el-button link type="primary" :disabled="!permissionUser.can('base:products:update')" @click="restoreSource(row)">恢复源值</el-button></div></template></el-table-column>
|
||||
<el-table-column label="最近发货数量" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价(元)" min-width="110" align="right">
|
||||
<el-table-column label="最近含税成交价(元)" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
|
||||
</template>
|
||||
@ -294,12 +307,13 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!permissionUser.can('base:products:update')" :title="permissionUser.permissionReason('base:products:update')">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)" :disabled="!permissionUser.can('base:products:status')" :title="permissionUser.permissionReason('base:products:status')">
|
||||
{{ row.status === 1 ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('base:products:delete')" :title="permissionUser.permissionReason('base:products:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -320,10 +334,10 @@ onMounted(() => {
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑产品' : '新增产品'"
|
||||
width="540px"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:products:update' : 'base:products:create')" :model="form" :rules="rules" label-width="156px">
|
||||
<el-form-item label="产品名称" prop="product_name">
|
||||
<el-input v-model="form.product_name" maxlength="128" placeholder="与规格构成组合唯一键" />
|
||||
</el-form-item>
|
||||
@ -333,10 +347,10 @@ onMounted(() => {
|
||||
<el-form-item label="计量单位" prop="unit">
|
||||
<el-input v-model="form.unit" maxlength="16" placeholder="如 台 / 套 / 米" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数量" prop="stock_quantity">
|
||||
<el-form-item label="最近发货数量" prop="stock_quantity">
|
||||
<el-input v-model="form.stock_quantity" placeholder="非负数字,选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单价(元)" prop="sale_price">
|
||||
<el-form-item label="最近含税成交价(元)" prop="sale_price">
|
||||
<el-input v-model="form.sale_price" placeholder="非负数字,选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类别" prop="product_category">
|
||||
@ -359,10 +373,11 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:products:update' : 'base:products:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:products:update' : 'base:products:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as payroll from '@/api/payroll'
|
||||
import { listPersonnel, type PersonnelItem } from '@/api/personnel'
|
||||
import { listShipments, getShipmentDetail } from '@/api/shipments'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { request } from '@/api/request'
|
||||
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
const open = defineModel<boolean>({required: true})
|
||||
const props = defineProps<{businessId: number}>()
|
||||
const user = useUserStore()
|
||||
const month = ref(''), opening = ref(''), evidence = ref(''), confirmed = ref(false)
|
||||
const busy = ref(false), loading = ref(false), failed = ref(false), locked = ref(true)
|
||||
const monthChecked = ref(false), monthLoading = ref(false)
|
||||
let monthRequest = 0
|
||||
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
|
||||
const people = ref<PersonnelItem[]>([])
|
||||
const people = ref<{id: number; personnel_name: string}[]>([])
|
||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string})[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@ -22,11 +25,9 @@ async function load(): Promise<void> {
|
||||
input.value = await payroll.getInput(props.businessId)
|
||||
rows.value = []
|
||||
if (!input.value || input.value.source === 'MANUAL') {
|
||||
const [persons, ships] = await Promise.all([listPersonnel({pageSize: 200}), listShipments({business_id: props.businessId, pageSize: 200})])
|
||||
if (persons.total > 200 || ships.total > 200) throw new Error('资料超过单次处理范围,请按业务拆分后处理')
|
||||
people.value = persons.list
|
||||
const details = await Promise.all(ships.list.map(s => getShipmentDetail(s.id)))
|
||||
rows.value = details.flatMap(s => s.items.map(i => ({shipment_item_id: i.id, name: `${s.shipment_no} · ${i.product_name}`, ship_date: s.ship_date, amount: i.amount, unit_cost: ''})))
|
||||
const data = await request<{personnel: typeof people.value; shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string}[]}>({url: `/v1/meta/payroll-input/${props.businessId}`})
|
||||
people.value = data.personnel
|
||||
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, unit_cost: ''}))
|
||||
if (input.value) {
|
||||
const data = input.value.data as {group?: {opening_balance?: string}; shipments?: {line_id: string; personnel_id: string; manager_id: string; cost_total: string; quantity: string; unit_cost?: string}[]; evidence?: string}
|
||||
opening.value = data.group?.opening_balance ?? ''
|
||||
@ -41,9 +42,18 @@ async function load(): Promise<void> {
|
||||
} catch (e) { failed.value = true; handledError(e) } finally { loading.value = false }
|
||||
}
|
||||
async function checkMonth(): Promise<void> {
|
||||
const request = ++monthRequest
|
||||
locked.value = true
|
||||
if (!month.value) return
|
||||
try { locked.value = (await payroll.monthState(month.value)).allocation_locked } catch (e) { handledError(e) }
|
||||
monthChecked.value = false
|
||||
if (!month.value) { monthLoading.value = false; return }
|
||||
monthLoading.value = true
|
||||
try {
|
||||
const state = await payroll.monthState(month.value)
|
||||
if (request !== monthRequest) return
|
||||
locked.value = state.allocation_locked
|
||||
monthChecked.value = true
|
||||
} catch (e) { handledError(e) }
|
||||
finally { if (request === monthRequest) monthLoading.value = false }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (!month.value || !confirmed.value || !opening.value || !evidence.value.trim() || rows.value.some(r => !r.personnel_id || !r.manager_id || !r.unit_cost)) {
|
||||
@ -60,35 +70,39 @@ async function save(): Promise<void> {
|
||||
}
|
||||
async function run(): Promise<void> {
|
||||
if (!month.value) { ElMessage.warning('请选择核算月份'); return }
|
||||
try { await ElMessageBox.confirm('本次将写入正式绩效台账。请确认资料已核对,尚未生成工资。', '正式核算', {type: 'warning'}) } catch { return }
|
||||
try { await ElMessageBox.confirm('将按所选月份生成正式绩效台账。请先核对期初应收、发货归属和指导成本;本操作不会直接生成月度工资。', '确认生成正式台账', {confirmButtonText:'生成正式台账', cancelButtonText:'返回核对', customClass:'ys-confirm-box'}) } catch { return }
|
||||
busy.value = true
|
||||
try { result.value = await payroll.calculate(props.businessId, month.value); ElMessage.success('正式核算完成') }
|
||||
try { result.value = await payroll.calculate(props.businessId, month.value); ElMessage.success('正式绩效台账已生成') }
|
||||
catch (e) { handledError(e) } finally { busy.value = false }
|
||||
}
|
||||
watch(open, value => { if (value) { month.value = ''; opening.value = ''; evidence.value = ''; confirmed.value = false; locked.value = true; void load() } })
|
||||
watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.value = ''; evidence.value = ''; confirmed.value = false; locked.value = true; monthChecked.value = false; monthLoading.value = false; void load() } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="open" title="正式工资核算" width="min(1100px, 96vw)">
|
||||
<el-dialog v-model="open" title="正式绩效核算" width="min(1100px, 96vw)">
|
||||
<div v-loading="loading">
|
||||
<p class="form-hint">本页生成绩效台账;月度工资需在“月度结算”中另行生成。</p>
|
||||
<p v-if="!permissionUser.can('payroll:calculate')" class="form-hint">当前账号可查看核算资料;没有正式核算权限,请联系管理员分配相应角色。</p>
|
||||
<el-alert v-if="failed" title="资料加载失败,请重试。" type="error" :closable="false" /><el-button v-if="failed" @click="load">重新加载</el-button>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="核算月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" @change="checkMonth" /></el-form-item>
|
||||
<el-alert v-if="month && locked" title="该月工资已生成,分配方案已锁定;差额请走当月调整单。" type="warning" :closable="false" />
|
||||
<p v-if="monthLoading" class="form-hint" role="status">正在核对该月工资状态…</p>
|
||||
<el-alert v-else-if="month && monthChecked && locked" title="该月工资已生成,分配方案已锁定;差额请通过当月调整单处理。" type="warning" :closable="false" />
|
||||
<el-alert v-else-if="month && !monthChecked" title="无法确认该月工资状态,请重新检查后再操作。" type="error" :closable="false"><el-button link @click="checkMonth">重新检查</el-button></el-alert>
|
||||
<p>资料版本:{{ input?.version ?? '尚未准备' }};来源:{{ input?.source === 'MANUAL' ? '人工核实' : input ? '源单据导入' : '—' }}</p>
|
||||
<template v-if="!failed && (!input || input.source === 'MANUAL')">
|
||||
<el-form-item label="2026 年 2 月末期初应收(元,核实为零时填写 0)"><el-input v-model="opening" :disabled="!user.canWritePayroll || locked" /></el-form-item>
|
||||
<el-form-item label="核对依据及修改原因"><el-input v-model="evidence" :disabled="!user.canWritePayroll || locked" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="2026 年 2 月末期初应收(元,核实为零时填写 0)"><el-input v-model="opening" :disabled="!permissionUser.can('payroll:prepare') || locked" /></el-form-item>
|
||||
<el-form-item label="核对依据及修改原因"><el-input v-model="evidence" :disabled="!permissionUser.can('payroll:prepare') || locked" maxlength="500" /></el-form-item>
|
||||
<el-table :data="rows" empty-text="请先登记发货及产品明细">
|
||||
<el-table-column prop="name" label="发货明细" min-width="190" /><el-table-column prop="amount" label="不含运费销售额" width="130" />
|
||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!user.canWritePayroll || locked" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :disabled="!user.canWritePayroll || locked" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="150"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!user.canWritePayroll || locked" aria-label="含税单位指导成本" /></template></el-table-column>
|
||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="150"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked" aria-label="含税单位指导成本" /></template></el-table-column>
|
||||
</el-table>
|
||||
<p><el-checkbox v-model="confirmed" :disabled="!user.canWritePayroll || locked">已核实期初与历史处理范围,发货月份指导成本录入完毕并封存</el-checkbox></p>
|
||||
<el-button :disabled="!user.canWritePayroll || locked || failed" :loading="busy" @click="save">保存核算资料</el-button>
|
||||
<p><el-checkbox v-model="confirmed" :disabled="!permissionUser.can('payroll:prepare') || locked">已核实期初与历史处理范围,发货月份指导成本录入完毕并封存</el-checkbox></p>
|
||||
<el-button :disabled="!permissionUser.can('payroll:prepare') || locked || failed" :loading="busy" @click="save" :title="permissionUser.permissionReason('payroll:prepare')">保存核算资料</el-button>
|
||||
</template>
|
||||
<el-button type="primary" :disabled="!user.canWritePayroll || locked || failed || !input" :loading="busy" @click="run">生成正式台账</el-button>
|
||||
<el-button type="primary" :disabled="!permissionUser.can('payroll:calculate') || locked || failed || !input" :loading="busy" @click="run" :title="permissionUser.permissionReason('payroll:calculate')">生成正式台账</el-button>
|
||||
</el-form>
|
||||
<el-alert v-if="result" :title="`计提 ${result.performance} 元,本期应付 ${result.payable} 元;${result.start_payment_ok ? '已达到起付门槛' : '尚未达到起付门槛'}`" type="success" :closable="false" />
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@ -32,7 +36,7 @@ import { handledError } from '@/utils/error'
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
import PayrollDialog from './PayrollDialog.vue'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
const payrollOpen = ref(false), payrollBusinessId = ref(0)
|
||||
|
||||
// ==================== 列表 ====================
|
||||
@ -78,14 +82,14 @@ interface PersonnelOption {
|
||||
const { options: customerOptions, load: loadCustomerOptions } = useDropdownOptions<CustomerOption>(
|
||||
async () => {
|
||||
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
|
||||
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
|
||||
const data = await optionPage<Awaited<ReturnType<typeof customerApi.listCustomers>>['list'][number]>('customers')
|
||||
return data.list.map((c) => ({ id: c.id, name: c.company_name }))
|
||||
},
|
||||
)
|
||||
|
||||
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions<PersonnelOption>(
|
||||
async () => {
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
const data = await optionPage<Awaited<ReturnType<typeof personnelApi.listPersonnel>>['list'][number]>('personnel')
|
||||
return data.list.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.personnel_name,
|
||||
@ -294,7 +298,7 @@ function validateItemRows(rows: ItemDraft[]): string | null {
|
||||
const prices = [
|
||||
['销售单价', row.sale_price, PRICE_PATTERN, '最多四位小数'],
|
||||
['成本额', row.cost_amount, AMOUNT_PATTERN, '最多两位小数'],
|
||||
['指导价', row.guided_price, PRICE_PATTERN, '最多四位小数'],
|
||||
['含税单位指导成本', row.guided_price, PRICE_PATTERN, '最多四位小数'],
|
||||
] as const
|
||||
for (const [label, value, pattern, hint] of prices) {
|
||||
if (value.trim() && !pattern.test(value.trim())) {
|
||||
@ -600,7 +604,7 @@ onMounted(() => {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">业务管理</h1>
|
||||
<p class="page-desc">
|
||||
业务台账是发货、回款与绩效的源头:客户、归属人员与产品类别在这里定下来,后面的核算才有依据
|
||||
维护客户、业务归属和产品明细,查看发货与回款进度,并进入正式核算。
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@ -648,7 +652,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增业务</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('business:businesses:create')" :title="permissionUser.permissionReason('business:businesses:create')">新增业务</el-button>
|
||||
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
|
||||
</div>
|
||||
|
||||
@ -710,20 +714,21 @@ onMounted(() => {
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="payrollBusinessId = row.id; payrollOpen = true">正式核算</el-button>
|
||||
<el-button link type="primary" size="small" @click="payrollBusinessId = row.id; payrollOpen = true" :disabled="!permissionUser.can('payroll:view')" :title="permissionUser.permissionReason('payroll:view')">正式核算</el-button>
|
||||
<el-tooltip
|
||||
content="已完成或已终止的业务禁止编辑"
|
||||
:disabled="row.status === 1"
|
||||
placement="top"
|
||||
>
|
||||
<span class="op-wrap">
|
||||
<el-button
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.status !== 1)"
|
||||
:disabled="!permissionUser.can('business:businesses:update') || (row.status !== 1)"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
:title="permissionUser.permissionReason('business:businesses:update')">
|
||||
编辑
|
||||
</el-button>
|
||||
</span>
|
||||
@ -734,18 +739,18 @@ onMounted(() => {
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.status !== 1)"
|
||||
:disabled="!permissionUser.can('business:businesses:status') || (row.status !== 1)"
|
||||
@click="openStatus(row)"
|
||||
>
|
||||
状态
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)" :disabled="!permissionUser.can('business:businesses:ownership')" :title="permissionUser.permissionReason('business:businesses:ownership')">
|
||||
归属
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('business:businesses:delete')" :title="permissionUser.permissionReason('business:businesses:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -770,7 +775,7 @@ onMounted(() => {
|
||||
width="980px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
|
||||
<el-form ref="formRef" :disabled="!permissionUser.can(isEdit ? 'business:businesses:update' : 'business:businesses:create')" :model="form" :rules="rules" label-width="96px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
@ -861,11 +866,11 @@ onMounted(() => {
|
||||
<el-table-column label="原参与人员"><template #default="{row}">{{ personnelOptions.find(p => p.id === row.personnel_id)?.name ?? row.personnel_id }}</template></el-table-column>
|
||||
<el-table-column prop="ratio" label="原分配比例(%)" />
|
||||
</el-table>
|
||||
<p class="page-desc">新核算按各人发货对应绩效分配,请使用“正式核算”维护发货归属。历史比例不参与新核算。</p>
|
||||
<p class="page-desc">正式核算按各人发货对应的绩效分配。请在“正式核算”中维护发货归属,历史比例方案仅供查看。</p>
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!payrollUser.canWritePayroll">添加一行</el-button>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!permissionUser.can(isEdit ? 'business:businesses:update' : 'business:businesses:create')">添加一行</el-button>
|
||||
</div>
|
||||
<el-table :data="itemRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="产品名称" min-width="170">
|
||||
@ -906,14 +911,14 @@ onMounted(() => {
|
||||
<el-input v-model="row.cost_amount" placeholder="元,选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指导价" min-width="110">
|
||||
<el-table-column label="含税单位指导成本" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.guided_price" placeholder="元,选填" />
|
||||
<el-input v-model="row.guided_price" placeholder="元/单位,选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)" :disabled="!permissionUser.can(isEdit ? 'business:businesses:update' : 'business:businesses:create')">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -925,7 +930,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'business:businesses:update' : 'business:businesses:create'))" :title="permissionUser.permissionReason((isEdit ? 'business:businesses:update' : 'business:businesses:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@ -954,7 +959,7 @@ onMounted(() => {
|
||||
<div class="op-warn">状态流转后终态不可再变更;变更将写入状态流转时间线。</div>
|
||||
<template #footer>
|
||||
<el-button @click="statusVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="statusSubmitting" @click="handleStatusSubmit" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button type="primary" :loading="statusSubmitting" @click="handleStatusSubmit" :disabled="!permissionUser.can('business:businesses:status')" :title="permissionUser.permissionReason('business:businesses:status')">
|
||||
确认变更
|
||||
</el-button>
|
||||
</template>
|
||||
@ -1011,7 +1016,7 @@ onMounted(() => {
|
||||
<div class="op-warn">归属变更仅影响变更后核算,不回溯历史。</div>
|
||||
<template #footer>
|
||||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit" :disabled="!payrollUser.canWritePayroll">
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit" :disabled="!permissionUser.can('business:businesses:ownership')" :title="permissionUser.permissionReason('business:businesses:ownership')">
|
||||
提交调整
|
||||
</el-button>
|
||||
</template>
|
||||
@ -1108,7 +1113,7 @@ onMounted(() => {
|
||||
<span class="cell-num">{{ row.cost_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指导价" min-width="100" align="right">
|
||||
<el-table-column label="含税单位指导成本" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.guided_price ?? '—' }}</span>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
@ -72,7 +76,7 @@ interface BusinessOption {
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
const data = await optionPage<Awaited<ReturnType<typeof businessApi.listBusinesses>>['list'][number]>('businesses')
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
@ -248,7 +252,7 @@ onMounted(() => {
|
||||
<!-- 页面头:标题 + 一句口径说明(回款是绩效源头)+ 签名水线 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">回款管理</h1>
|
||||
<p class="page-desc">回款是绩效的源头:批次、到账日与产品类别决定计提口径</p>
|
||||
<p class="page-desc">登记到账日期和回款金额,正式核算按先发货先平账的顺序分配回款。</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
@ -316,8 +320,8 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增回款</el-button>
|
||||
<span class="toolbar-note">已核算的回款不可变更;批次一旦指定,回款天数即按发货日派生</span>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('business:receipts:create')" :title="permissionUser.permissionReason('business:receipts:create')">新增回款</el-button>
|
||||
<span class="toolbar-note">已核算回款不能直接修改。已生成工资的差额,请通过当月调整单处理。</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@ -384,13 +388,14 @@ onMounted(() => {
|
||||
placement="top"
|
||||
>
|
||||
<span class="op-wrap">
|
||||
<el-button
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.settlement_status === 1)"
|
||||
:disabled="!permissionUser.can('business:receipts:update') || (row.settlement_status === 1)"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
:title="permissionUser.permissionReason('business:receipts:update')">
|
||||
编辑
|
||||
</el-button>
|
||||
</span>
|
||||
@ -406,9 +411,9 @@ onMounted(() => {
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.settlement_status === 1)"
|
||||
:disabled="!permissionUser.can('business:receipts:delete') || (row.settlement_status === 1)"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
:title="permissionUser.permissionReason('business:receipts:delete')">
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
@ -437,7 +442,7 @@ onMounted(() => {
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form ref="formRef" :disabled="!permissionUser.can(isEdit ? 'business:receipts:update' : 'business:receipts:create')" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item v-if="!isEdit" label="业务" prop="business_id">
|
||||
<el-select
|
||||
v-model="form.business_id"
|
||||
@ -461,7 +466,7 @@ onMounted(() => {
|
||||
<el-form-item label="发货批次" prop="shipment_id">
|
||||
<el-select
|
||||
v-model="form.shipment_id"
|
||||
placeholder="选业务后级联加载该业务发货单"
|
||||
placeholder="先选择业务,再选择发货单(选填)"
|
||||
clearable
|
||||
filterable
|
||||
:loading="shipmentLoading"
|
||||
@ -475,7 +480,7 @@ onMounted(() => {
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="field-hint">可清空(暂不指定);指定后回款天数方可派生(发货日计第 1 天)</span>
|
||||
<span class="field-hint">可留空。正式核算按发货顺序分配回款;逐笔回款天数可在正式台账中查看。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="回款到账日" prop="receipt_date">
|
||||
<el-date-picker
|
||||
@ -491,7 +496,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item label="以物抵顶" prop="is_barter">
|
||||
<el-switch v-model="form.is_barter" :active-value="1" :inactive-value="0" />
|
||||
<span class="switch-hint">绩效按 80% 折算,比例见参数配置</span>
|
||||
<span class="switch-hint">特殊抵顶业务单独登记,当前按线下流程核算,不进入普通回款核算。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||||
@ -499,7 +504,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'business:receipts:update' : 'business:receipts:create'))" :title="permissionUser.permissionReason((isEdit ? 'business:receipts:update' : 'business:receipts:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
@ -64,7 +68,7 @@ interface BusinessOption {
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
const data = await optionPage<Awaited<ReturnType<typeof businessApi.listBusinesses>>['list'][number]>('businesses')
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
@ -280,7 +284,7 @@ onMounted(() => {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">发货管理</h1>
|
||||
<p class="page-desc">
|
||||
发货单挂靠执行中的业务,发货日就是回款天数的起算点(发货日计第 1 天);批次与金额经回款指定后决定计提比例与绩效
|
||||
记录每次发货的日期、产品和金额。发货日计为回款天数的第 1 天,正式核算按发货先后顺序平账。
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@ -324,7 +328,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增发货单</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('business:shipments:create')" :title="permissionUser.permissionReason('business:shipments:create')">新增发货单</el-button>
|
||||
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
|
||||
</div>
|
||||
|
||||
@ -369,9 +373,10 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!permissionUser.can('business:shipments:update')" :title="permissionUser.permissionReason('business:shipments:update')">编辑</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('business:shipments:delete')" :title="permissionUser.permissionReason('business:shipments:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -396,7 +401,7 @@ onMounted(() => {
|
||||
width="820px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form ref="formRef" :disabled="!permissionUser.can(isEdit ? 'business:shipments:update' : 'business:shipments:create')" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item v-if="!isEdit" label="业务" prop="business_id">
|
||||
<el-select
|
||||
v-model="form.business_id"
|
||||
@ -427,13 +432,13 @@ onMounted(() => {
|
||||
<span class="field-hint">回款天数计算基准日(发货日计第 1 天)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费(元)" prop="freight_amount">
|
||||
<el-input v-model="form.freight_amount" placeholder="选填;仅记录展示,不计入毛利润口径" />
|
||||
<el-input v-model="form.freight_amount" placeholder="选填;运费单独记录,不计入绩效计算的销售额与毛利润" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">发货明细(至少 1 行,编辑为全量替换)</span>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!payrollUser.canWritePayroll">添加一行</el-button>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!permissionUser.can(isEdit ? 'business:shipments:update' : 'business:shipments:create')">添加一行</el-button>
|
||||
</div>
|
||||
<el-table :data="itemRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="产品名称" min-width="180">
|
||||
@ -463,7 +468,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)" :disabled="!permissionUser.can(isEdit ? 'business:shipments:update' : 'business:shipments:create')">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -475,7 +480,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'business:shipments:update' : 'business:shipments:create'))" :title="permissionUser.permissionReason((isEdit ? 'business:shipments:update' : 'business:shipments:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import PayrollAccessNotice from '@/components/feedback/PayrollAccessNotice.vue'
|
||||
import LedgerCalculationDetails from '@/components/payroll/LedgerCalculationDetails.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
@ -8,6 +14,7 @@ import { ElMessage } from 'element-plus'
|
||||
import * as calcApi from '@/api/calc'
|
||||
import * as payrollApi from '@/api/payroll'
|
||||
import type { LedgerRow } from '@/api/calc'
|
||||
import { listPersonnel } from '@/api/personnel'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
@ -47,10 +54,27 @@ const recalcReason = ref('')
|
||||
const allocationVisible = ref(false), allocationLoading = ref(false)
|
||||
const allocationRows = ref<Record<string, unknown>[]>([])
|
||||
const adjustmentOpen = ref(false), adjustmentBusy = ref(false)
|
||||
const adjustmentPeople = ref<{ id: number; name: string }[]>([])
|
||||
const adjustmentPeopleLoading = ref(false)
|
||||
let adjustmentPeopleRequest = 0
|
||||
async function searchAdjustmentPeople(keyword = ''): Promise<void> {
|
||||
const request = ++adjustmentPeopleRequest
|
||||
adjustmentPeopleLoading.value = true
|
||||
try {
|
||||
const result = await optionPage<Awaited<ReturnType<typeof listPersonnel>>['list'][number]>('personnel', {search: keyword || undefined})
|
||||
if (request !== adjustmentPeopleRequest) return
|
||||
const selected = adjustmentPeople.value.find(person => person.id === adjustment.personnel_id)
|
||||
const rows = result.list.map(person => ({ id: person.id, name: person.personnel_name }))
|
||||
adjustmentPeople.value = selected && !rows.some(person => person.id === selected.id) ? [selected, ...rows] : rows
|
||||
} catch (error) { handledError(error) }
|
||||
finally { if (request === adjustmentPeopleRequest) adjustmentPeopleLoading.value = false }
|
||||
}
|
||||
const adjustment = reactive({ledger_id: 0, personnel_id: 0, direction: 1, amount: '', reason: ''})
|
||||
function openAdjustment(row: LedgerRow): void {
|
||||
Object.assign(adjustment, {ledger_id: row.id, personnel_id: row.personnel_id, direction: 1, amount: '', reason: ''})
|
||||
adjustmentPeople.value = [{ id: row.personnel_id, name: row.personnel_name }]
|
||||
adjustmentOpen.value = true
|
||||
void searchAdjustmentPeople()
|
||||
}
|
||||
async function saveAdjustment(): Promise<void> {
|
||||
if (!adjustment.amount || !adjustment.reason.trim()) { ElMessage.warning('请填写差额和调整依据'); return }
|
||||
@ -98,10 +122,11 @@ async function submitRecalc(): Promise<void> {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">绩效台账</h1>
|
||||
<p class="page-desc">
|
||||
每笔回款只核算一次;重算作废旧版本、按当前参数落新版本并留痕,展开行可看当次核算的参数快照
|
||||
查看正式绩效台账及逐笔计算依据。重算保留历史版本;已生成工资的差额通过当月调整单处理。
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
<PayrollAccessNotice action="执行核算或生成调整单" />
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||
@ -145,7 +170,7 @@ async function submitRecalc(): Promise<void> {
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
|
||||
<LedgerCalculationDetails :ledger="row" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settlement_month" label="归属月" width="90" />
|
||||
@ -155,7 +180,7 @@ async function submitRecalc(): Promise<void> {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="personnel_name" label="归属人员" width="100" />
|
||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||
<el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column>
|
||||
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
||||
<el-table-column label="天数" width="70" align="right">
|
||||
<template #default="{ row }">
|
||||
@ -196,12 +221,12 @@ async function submitRecalc(): Promise<void> {
|
||||
link
|
||||
type="primary"
|
||||
@click="openRecalc(row)"
|
||||
:disabled="!payrollUser.canWritePayroll"
|
||||
>
|
||||
:disabled="!permissionUser.can('payroll:calculate')"
|
||||
:title="permissionUser.permissionReason('payroll:calculate')">
|
||||
重算
|
||||
</el-button>
|
||||
<el-button v-if="row.snapshot_json?.engine === 'FIFO'" link type="primary" @click="showAllocations(row)">分配依据</el-button>
|
||||
<el-button v-if="row.settlement_id" link type="primary" :disabled="!payrollUser.canWritePayroll" @click="openAdjustment(row)">分配调整</el-button>
|
||||
<el-button v-if="row.settlement_id" link type="primary" :disabled="!permissionUser.can('adjustments:create')" @click="openAdjustment(row)" :title="permissionUser.permissionReason('adjustments:create')">分配调整</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -232,7 +257,7 @@ async function submitRecalc(): Promise<void> {
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="recalcVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitRecalc" :disabled="!payrollUser.canWritePayroll">确认重算</el-button>
|
||||
<el-button type="primary" @click="submitRecalc" :disabled="!permissionUser.can('payroll:calculate')" :title="permissionUser.permissionReason('payroll:calculate')">确认重算</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@ -246,9 +271,9 @@ async function submitRecalc(): Promise<void> {
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="adjustmentOpen" title="二次分配差额调整" width="min(560px, 94vw)">
|
||||
<el-alert title="原工资保持不变,补扣计入调整单生成当月,并沿用现有审批流程。" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="调整人员编号"><el-input-number v-model="adjustment.personnel_id" :min="1" /></el-form-item><el-form-item label="补扣方向"><el-select v-model="adjustment.direction"><el-option :value="1" label="补发" /><el-option :value="2" label="扣减" /></el-select></el-form-item><el-form-item label="差额(元)"><el-input v-model="adjustment.amount" /></el-form-item><el-form-item label="调整依据"><el-input v-model="adjustment.reason" type="textarea" maxlength="255" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="adjustmentOpen = false">取消</el-button><el-button type="primary" :disabled="!payrollUser.canWritePayroll" :loading="adjustmentBusy" @click="saveAdjustment">生成当月调整单</el-button></template>
|
||||
<el-alert :title="payrollUser.userInfo?.payroll_approval_enabled ? '原工资保持不变,补扣金额计入调整单生成当月。是否需要审核,按当前调整单设置执行。' : '原工资保持不变,补扣金额计入调整单生成当月。当前无需审核,提交后直接生效。'" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="调整人员"><el-select v-model="adjustment.personnel_id" filterable remote :remote-method="searchAdjustmentPeople" :loading="adjustmentPeopleLoading" placeholder="搜索并选择人员" aria-label="调整人员"><el-option v-for="person in adjustmentPeople" :key="person.id" :value="person.id" :label="person.name" /></el-select><p class="form-hint">可调整原归属人员或已登记交接范围内的人员。</p></el-form-item><el-form-item label="补扣方向"><el-select v-model="adjustment.direction"><el-option :value="1" label="补发" /><el-option :value="2" label="扣减" /></el-select></el-form-item><el-form-item label="差额(元)"><el-input v-model="adjustment.amount" /></el-form-item><el-form-item label="调整依据"><el-input v-model="adjustment.reason" type="textarea" maxlength="255" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="adjustmentOpen = false">取消</el-button><el-button type="primary" :disabled="!permissionUser.can('adjustments:create')" :loading="adjustmentBusy" @click="saveAdjustment" :title="permissionUser.permissionReason('adjustments:create')">生成当月调整单</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@ -256,16 +281,6 @@ async function submitRecalc(): Promise<void> {
|
||||
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
|
||||
此处只保留本页特有的快照块与重算提示 */
|
||||
|
||||
.snapshot {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.recalc-warn {
|
||||
margin: 0 0 10px;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
@ -12,6 +12,8 @@ import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/ap
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||||
|
||||
@ -23,7 +25,7 @@ echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||||
* 只能截断前 20 条。翻页只重取这张列表,不重算其余三区块
|
||||
* - 总览图表严格按设计预览:SVG 渲染、无网格线、靛蓝渐变;
|
||||
* **必须显式设置 fontFamily**——ECharts 在 Windows 默认微软雅黑(字体合规红线)
|
||||
* - ⑥ T+ 同步未建:同步失败待办/同步预警显示「模块未启用」,不显示 0 条
|
||||
* - 同步采集记录与当前读取/自动采集开关分别展示
|
||||
*
|
||||
* 版式口径(本轮重排):
|
||||
* - 旧版把所有卡片塞进一个 `grid-auto-rows: 102px` 的等高网格,列表容器
|
||||
@ -34,6 +36,8 @@ echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const approvalEnabled = computed(() => user.userInfo?.payroll_approval_enabled === true)
|
||||
|
||||
const CHART_FONT =
|
||||
"'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
|
||||
@ -49,9 +53,18 @@ const ORDER_TYPE_LABELS: Record<number, string> = {
|
||||
2: '月度结算',
|
||||
3: '调整单',
|
||||
}
|
||||
const SYNC_STATUS_LABELS: Record<string, string> = {
|
||||
SUCCEEDED: '最近采集成功',
|
||||
FAILED: '最近采集失败',
|
||||
PARTIAL: '最近采集部分失败',
|
||||
QUEUED: '采集排队中',
|
||||
RUNNING: '正在采集',
|
||||
}
|
||||
|
||||
const month = ref(currentMonth())
|
||||
const data = ref<DashboardData | null>(null)
|
||||
const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false)
|
||||
const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
|
||||
const loading = ref(false)
|
||||
/** 快速切换月份时,只允许最新请求写入数据与 loading */
|
||||
let loadSeq = 0
|
||||
@ -89,11 +102,11 @@ async function load(): Promise<void> {
|
||||
|
||||
function renderCharts(): void {
|
||||
const trend = data.value?.overview.trend ?? []
|
||||
if (perfChartRef.value) {
|
||||
if (perfChartRef.value && hasPerformanceTrend.value) {
|
||||
perfChart ??= echarts.init(perfChartRef.value, undefined, { renderer: 'svg' })
|
||||
perfChart.setOption(performanceOption(trend))
|
||||
}
|
||||
if (receiptChartRef.value) {
|
||||
if (receiptChartRef.value && hasReceiptTrend.value) {
|
||||
receiptChart ??= echarts.init(receiptChartRef.value, undefined, { renderer: 'svg' })
|
||||
receiptChart.setOption(receiptOption(trend))
|
||||
}
|
||||
@ -320,14 +333,16 @@ function formatTime(value: string): string {
|
||||
<span class="panel-title">近 6 个月绩效</span>
|
||||
<span class="panel-hint">按台账归属月</span>
|
||||
</div>
|
||||
<div ref="perfChartRef" class="chart"></div>
|
||||
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart"></div>
|
||||
<div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">近 6 个月回款</span>
|
||||
<span class="panel-hint">按到账月</span>
|
||||
</div>
|
||||
<div ref="receiptChartRef" class="chart"></div>
|
||||
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart"></div>
|
||||
<div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -339,7 +354,7 @@ function formatTime(value: string): string {
|
||||
<span class="panel-hint">点击条目进入对应模块</span>
|
||||
</div>
|
||||
|
||||
<div class="list-block">
|
||||
<div v-if="approvalEnabled" class="list-block">
|
||||
<div class="list-block__head">
|
||||
<span class="list-block__title">待审批</span>
|
||||
<span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
||||
@ -365,10 +380,14 @@ function formatTime(value: string): string {
|
||||
</div>
|
||||
<div v-else class="list-empty">无待审批事项</div>
|
||||
</div>
|
||||
<div v-else class="status-strip">
|
||||
<span class="status-strip__label">工资审核</span>
|
||||
<span class="status-strip__value">当前已关闭,工资生成后直接完成</span>
|
||||
</div>
|
||||
|
||||
<div class="list-block">
|
||||
<div class="list-block__head">
|
||||
<span class="list-block__title">回款未指定批次</span>
|
||||
<span class="list-block__title">未指定批次的回款(供核对)</span>
|
||||
<span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
||||
<button class="list-block__more" type="button" @click="go('/business/receipts')">
|
||||
全部 →
|
||||
@ -392,12 +411,13 @@ function formatTime(value: string): string {
|
||||
</div>
|
||||
|
||||
<div class="status-strip">
|
||||
<span class="status-strip__label">T+ 同步失败</span>
|
||||
<span class="status-strip__label">T+ 数据采集</span>
|
||||
<span class="status-strip__value">
|
||||
{{
|
||||
data?.todos.sync_available
|
||||
? `${data.todos.sync_failure_total} 条待处置`
|
||||
: '同步模块未启用(待 T+ 对接)'
|
||||
!data ? '正在加载采集状态…' : !data.sync.available ? '采集记录暂不可查询'
|
||||
: data.sync.last_success_at
|
||||
? `最近成功:${formatDateTime(data.sync.last_success_at)}`
|
||||
: '尚无成功采集记录'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@ -410,7 +430,7 @@ function formatTime(value: string): string {
|
||||
</div>
|
||||
|
||||
<div v-if="data && !data.warnings.enabled" class="list-empty list-empty--block">
|
||||
预警已关闭(参数 warning.enabled=0)
|
||||
业务风险预警已关闭,可在计提参数中调整。
|
||||
</div>
|
||||
<div v-else-if="data?.warnings.items.length" class="list-scroll">
|
||||
<button
|
||||
@ -434,8 +454,8 @@ function formatTime(value: string): string {
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="list-empty list-empty--block">
|
||||
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 / 审批超时
|
||||
{{ data?.warnings.approval_timeout_days ?? 3 }} 天)
|
||||
暂无风险预警。回款逾期提醒:{{ data?.warnings.receipt_overdue_days ?? 90 }} 天。
|
||||
<template v-if="approvalEnabled">审核超时提醒:{{ data?.warnings.approval_timeout_days ?? 3 }} 天。</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -446,9 +466,15 @@ function formatTime(value: string): string {
|
||||
</div>
|
||||
|
||||
<div class="status-strip">
|
||||
<span class="status-strip__label">同步异常预警</span>
|
||||
<span class="status-strip__label">采集运行状态</span>
|
||||
<span class="status-strip__value">
|
||||
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
|
||||
<template v-if="data?.sync.available">
|
||||
{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }};
|
||||
{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }};
|
||||
{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}
|
||||
<span v-if="data.sync.historical_failure_total">;历史异常 {{ data.sync.historical_failure_total }} 批次</span>
|
||||
</template>
|
||||
<template v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -691,6 +717,9 @@ function formatTime(value: string): string {
|
||||
height: 190px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.chart-empty { min-height: 190px; padding: 32px 24px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 10px; }
|
||||
.chart-empty strong { font-size: 14px; font-weight: 500; color: var(--ys-ink); }
|
||||
.chart-empty p { max-width: 440px; margin: 0; font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); }
|
||||
|
||||
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
|
||||
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
|
||||
|
||||
5
frontend/src/views/no-access/index.vue
Normal file
5
frontend/src/views/no-access/index.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const user = useUserStore()
|
||||
</script>
|
||||
<template><section class="card card-body"><h1 class="page-title">暂无可访问功能</h1><p>当前账号尚未获得功能权限,请联系系统管理员在用户管理中授权。</p><el-button @click="user.fetchUserInfo">重新检查权限</el-button></section></template>
|
||||
@ -1,4 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@ -34,12 +38,12 @@ const filters = reactive({
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const { options: customerOptions, load: loadCustomers } = useDropdownOptions(() =>
|
||||
listCustomers({ pageNum: 1, pageSize: 200 }).then((data) =>
|
||||
optionPage<Awaited<ReturnType<typeof listCustomers>>['list'][number]>('customers').then((data) =>
|
||||
data.list.map((item) => ({ label: item.company_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions(() =>
|
||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||
optionPage<Awaited<ReturnType<typeof listPersonnel>>['list'][number]>('personnel').then((data) =>
|
||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
@ -234,8 +238,8 @@ function today(): string {
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||
<el-button @click="doPrint">打印</el-button>
|
||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('reports:business:export')" :title="permissionUser.permissionReason('reports:business:export')">导出 Excel</el-button>
|
||||
<el-button @click="doPrint" :disabled="!permissionUser.can('reports:business:export')" :title="permissionUser.permissionReason('reports:business:export')">打印</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@ -26,7 +31,7 @@ const filters = reactive({
|
||||
})
|
||||
|
||||
const { options: managerOptions, load: loadManagers } = useDropdownOptions(() =>
|
||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||
optionPage<Awaited<ReturnType<typeof listPersonnel>>['list'][number]>('personnel').then((data) =>
|
||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
@ -134,6 +139,8 @@ function pct(value: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirements-surface">
|
||||
<ProbationPanel />
|
||||
<div class="page">
|
||||
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||
<div class="page-head">
|
||||
@ -179,8 +186,8 @@ function pct(value: string): string {
|
||||
</el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||
<el-button @click="doPrint">打印</el-button>
|
||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('reports:department:export')" :title="permissionUser.permissionReason('reports:department:export')">导出 Excel</el-button>
|
||||
<el-button @click="doPrint" :disabled="!permissionUser.can('reports:department:export')" :title="permissionUser.permissionReason('reports:department:export')">打印</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
@ -208,7 +215,8 @@ function pct(value: string): string {
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-table
|
||||
<section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
class="dense"
|
||||
:data="data?.teams ?? []"
|
||||
@ -332,6 +340,7 @@ function pct(value: string): string {
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@ -220,8 +223,8 @@ function today(): string {
|
||||
</el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||
<el-button @click="doPrint">打印</el-button>
|
||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('reports:payments:export')" :title="permissionUser.permissionReason('reports:payments:export')">导出 Excel</el-button>
|
||||
<el-button @click="doPrint" :disabled="!permissionUser.can('reports:payments:export')" :title="permissionUser.permissionReason('reports:payments:export')">打印</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import LedgerCalculationDetails from '@/components/payroll/LedgerCalculationDetails.vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@ -50,7 +55,7 @@ function applyRouteQuery(): void {
|
||||
applyRouteQuery()
|
||||
|
||||
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||
optionPage<Awaited<ReturnType<typeof listPersonnel>>['list'][number]>('personnel').then((data) =>
|
||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
@ -108,14 +113,21 @@ function resetFilters(): void {
|
||||
|
||||
/** 快照懒加载缓存:同一台账行只取一次 */
|
||||
const snapshots = ref<Record<number, LedgerRow>>({})
|
||||
const snapshotLoading = ref<Record<number, boolean>>({})
|
||||
const snapshotFailed = ref<Record<number, boolean>>({})
|
||||
|
||||
async function loadSnapshot(row: PersonalDetailRow): Promise<void> {
|
||||
if (snapshots.value[row.ledger_id]) return
|
||||
if (snapshots.value[row.ledger_id] || snapshotLoading.value[row.ledger_id]) return
|
||||
snapshotLoading.value[row.ledger_id] = true
|
||||
snapshotFailed.value[row.ledger_id] = false
|
||||
try {
|
||||
const ledger = await calcApi.getLedger(row.ledger_id)
|
||||
snapshots.value = { ...snapshots.value, [row.ledger_id]: ledger }
|
||||
} catch (error) {
|
||||
snapshotFailed.value[row.ledger_id] = true
|
||||
handledError(error)
|
||||
} finally {
|
||||
snapshotLoading.value[row.ledger_id] = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -243,8 +255,8 @@ function today(): string {
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||
<el-button @click="doPrint">打印</el-button>
|
||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('reports:personal:export')" :title="permissionUser.permissionReason('reports:personal:export')">导出 Excel</el-button>
|
||||
<el-button @click="doPrint" :disabled="!permissionUser.can('reports:personal:export')" :title="permissionUser.permissionReason('reports:personal:export')">打印</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
@ -260,9 +272,10 @@ function today(): string {
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<pre class="snapshot">{{
|
||||
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
|
||||
}}</pre>
|
||||
<el-alert v-if="snapshotFailed[row.ledger_id]" type="error" title="计算依据加载失败,请重试。" :closable="false">
|
||||
<el-button link @click="loadSnapshot(row)">重新加载</el-button>
|
||||
</el-alert>
|
||||
<LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="160">
|
||||
@ -373,16 +386,6 @@ function today(): string {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
background: var(--ys-mist);
|
||||
border-radius: var(--ys-radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--ys-danger);
|
||||
|
||||
@ -406,6 +406,14 @@ onMounted(load)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-if="department.salary_rows?.length">
|
||||
<h2 class="block-title">六、已生成工资分项快照</h2>
|
||||
<table class="sheet-table" data-testid="print-salary-snapshot">
|
||||
<thead><tr><th>人员</th><th class="num">基本工资</th><th class="num">正常绩效</th><th class="num">试用期缓发</th><th class="num">年终/续付差额</th><th class="num">本月实付绩效</th><th class="num">净工资</th></tr></thead>
|
||||
<tbody><tr v-for="item in department.salary_rows" :key="item.personnel_id"><td>{{item.personnel_name}}</td><td class="num">{{item.basic_amount ?? '—'}}</td><td class="num">{{item.normal_payable_sum}}</td><td class="num">{{item.deferred_sum}}</td><td class="num">{{item.probation_payment_sum}}</td><td class="num">{{item.payable_sum}}</td><td class="num">{{item.net_amount ?? '—'}}</td></tr></tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
|
||||
<template v-else-if="code === 'business-stats'">
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import httpService from '@/api/request'
|
||||
import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import PayrollAccessNotice from '@/components/feedback/PayrollAccessNotice.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
@ -80,13 +86,13 @@ const generating = ref(false)
|
||||
|
||||
async function handleGenerate(): Promise<void> {
|
||||
if (!/^\d{4}-\d{2}$/.test(generateMonth.value)) {
|
||||
ElMessage.warning('请输入结算月份(YYYY-MM)')
|
||||
ElMessage.warning('请选择需要生成工资的月份')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
await generateSettlement(generateMonth.value)
|
||||
ElMessage.success('工资已生成并锁定,无需系统审批')
|
||||
const generated = await generateSettlement(generateMonth.value)
|
||||
ElMessage.success(generated.approval_required ? '工资快照已生成,请继续提交审核' : '工资已生成并锁定')
|
||||
await search()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
@ -95,6 +101,7 @@ async function handleGenerate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSalary(month:string): Promise<void> {try {const response=await httpService.get(`/v1/payroll/salary-export/${month}`,{responseType:'blob'});const url=URL.createObjectURL(response.data);const a=document.createElement('a');a.href=url;a.download=`月度工资分项_${month}.xlsx`;a.click();URL.revokeObjectURL(url)}catch(error){handledError(error)}}
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<SettlementDetail | null>(null)
|
||||
@ -121,14 +128,17 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirements-surface">
|
||||
<ProbationPanel />
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">月度结算</h1>
|
||||
<p class="page-desc">
|
||||
按月生成工资并锁定分配;审核功能默认关闭,完成后可直接查看和导出
|
||||
按月生成工资并锁定当月分配。{{ payrollUser.userInfo?.payroll_approval_enabled ? '需要审核的记录可继续提交审核。' : '当前无需系统审核,生成后即可查看和导出。' }}
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
<PayrollAccessNotice action="生成或提交月度工资" />
|
||||
|
||||
<!--
|
||||
月结生命周期条:用 button 而非 div —— 它可点击、可聚焦,
|
||||
@ -151,14 +161,16 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
|
||||
<!-- 生成行:文案保留「生成月结快照」,对应真实动作(生成的是快照批次) -->
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
<el-date-picker
|
||||
v-model="generateMonth"
|
||||
placeholder="结算月份 YYYY-MM"
|
||||
style="width: 160px"
|
||||
maxlength="7"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择结算月份"
|
||||
aria-label="结算月份"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate" :disabled="!payrollUser.canWritePayroll">
|
||||
生成月结快照
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate" :disabled="!permissionUser.can('settlements:generate')" :title="permissionUser.permissionReason('settlements:generate')">
|
||||
生成月度工资
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@ -220,7 +232,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="row.approval_required && (row.status === 1 || row.status === 4) && payrollUser.userInfo?.payroll_approval_enabled" link type="success" :disabled="!payrollUser.canWritePayroll" :loading="submitting" @click="handleSubmit(row)">提交审核</el-button>
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="row.approval_required && (row.status === 1 || row.status === 4) && payrollUser.userInfo?.payroll_approval_enabled" link type="success" :disabled="!permissionUser.can('settlements:submit')" :loading="submitting" @click="handleSubmit(row)" :title="permissionUser.permissionReason('settlements:submit')">提交审核</el-button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@ -238,7 +250,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
|
||||
<el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
@ -261,7 +273,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<dd>{{ detail.locked_by || '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
<p class="section-title section-title--gap">按人明细</p>
|
||||
<p class="section-title section-title--gap">按人明细</p><el-button :disabled="!permissionUser.can('reports:personal:export')" @click="exportSalary(detail.settlement_month)">导出工资分项 Excel</el-button>
|
||||
<el-table :data="detail.items" size="small" stripe>
|
||||
<el-table-column prop="personnel_name" label="人员" min-width="90" />
|
||||
<el-table-column label="类型" width="90">
|
||||
@ -284,6 +296,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="basic_amount" label="基本工资" min-width="110" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" />
|
||||
<el-table-column label="试用期" width="70">
|
||||
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
@ -299,6 +312,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
@ -124,7 +127,9 @@ const actionForm = reactive({
|
||||
|
||||
/** 仅在途单展示动作表单;动作随单据状态定(1 待财务 / 2 待领导) */
|
||||
const canAct = (order: ApprovalOrderDetail): boolean =>
|
||||
order.status === 1 || order.status === 2
|
||||
permissionUser.userInfo?.payroll_approval_enabled === true && (
|
||||
order.status === 1 ? permissionUser.can('approvals:finance') : order.status === 2 && permissionUser.can('approvals:leader')
|
||||
)
|
||||
|
||||
function openAction(order: ApprovalOrderDetail, action: 1 | 2): void {
|
||||
actingId.value = order.id
|
||||
@ -167,6 +172,7 @@ async function submitAction(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-alert v-if="!permissionUser.userInfo?.payroll_approval_enabled" title="当前审核流程未开启,可查看历史审核记录,不能执行通过或退回。" type="info" :closable="false" />
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">审批管理</h1>
|
||||
<p class="page-desc">
|
||||
@ -469,7 +475,7 @@ async function submitAction(): Promise<void> {
|
||||
placeholder="审批意见将随节点留痕"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction">
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction" :disabled="!detail || !canAct(detail)">
|
||||
确认{{ actionForm.action === 1 ? '通过' : '驳回' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import type { BusinessRoleItem, TemplateOption } from '@/api/businessRoles'
|
||||
import * as paramsApi from '@/api/params'
|
||||
import type { BusinessRoleItem, CalcTemplateItem, FormulaOption, TemplateOption } from '@/api/businessRoles'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
@ -53,6 +57,141 @@ function templateName(code: string): string {
|
||||
return templateOptions.value.find((tpl) => tpl.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
// ==================== 公式模板管理 ====================
|
||||
// 模板定义入库后,岗位新增不再受内置 5 个模板限制;骨架仍为受控枚举,
|
||||
// 页面只配置名称/说明/参数绑定,避免自由公式带来的执行风险。
|
||||
const templateManagerVisible = ref(false)
|
||||
const templates = ref<CalcTemplateItem[]>([])
|
||||
const formulas = ref<FormulaOption[]>([])
|
||||
const templateLoading = ref(false)
|
||||
const templateSubmitting = ref(false)
|
||||
const templateDialogVisible = ref(false)
|
||||
const templateEditingId = ref<number | null>(null)
|
||||
const paramOptions = ref<{ code: string; name: string; group: string }[]>([])
|
||||
|
||||
const templateFormRef = ref<FormInstance>()
|
||||
const templateForm = reactive({
|
||||
template_code: '',
|
||||
template_name: '',
|
||||
formula_type: '',
|
||||
params: {} as Record<string, string>,
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
const templateRules = computed<FormRules>(() => ({
|
||||
template_code: templateEditingId.value === null
|
||||
? [
|
||||
{ required: true, message: '请输入模板编码', trigger: 'blur' },
|
||||
{ pattern: /^[A-Z][A-Z0-9_]{2,31}$/, message: '编码以大写字母开头,仅可含大写字母、数字与下划线', trigger: 'blur' },
|
||||
]
|
||||
: [],
|
||||
template_name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
formula_type: [{ required: true, message: '请选择公式骨架', trigger: 'change' }],
|
||||
}))
|
||||
|
||||
const editingTemplate = computed(() =>
|
||||
templateEditingId.value === null ? null : templates.value.find((t) => t.id === templateEditingId.value) ?? null,
|
||||
)
|
||||
const selectedFormula = computed(() => formulas.value.find((f) => f.code === templateForm.formula_type) ?? null)
|
||||
|
||||
function formulaName(code: string): string {
|
||||
return formulas.value.find((f) => f.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
function paramName(code: string): string {
|
||||
return paramOptions.value.find((item) => item.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
async function loadTemplateManager(): Promise<void> {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const [tplList, formulaList] = await Promise.all([businessRoleApi.listCalcTemplates(), businessRoleApi.listFormulaOptions()])
|
||||
templates.value = tplList
|
||||
formulas.value = formulaList
|
||||
if (paramOptions.value.length === 0) {
|
||||
const catalog = await paramsApi.getCatalog()
|
||||
paramOptions.value = catalog.map((item) => ({ code: item.param_code, name: item.param_name, group: item.param_group }))
|
||||
}
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTemplateManager(): void {
|
||||
templateManagerVisible.value = true
|
||||
void loadTemplateManager()
|
||||
}
|
||||
|
||||
function openTemplateCreate(): void {
|
||||
templateEditingId.value = null
|
||||
Object.assign(templateForm, { template_code: '', template_name: '', formula_type: '', params: {}, description: '', sort_order: 0 })
|
||||
templateDialogVisible.value = true
|
||||
templateFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openTemplateEdit(row: CalcTemplateItem): void {
|
||||
templateEditingId.value = row.id
|
||||
Object.assign(templateForm, {
|
||||
template_code: row.template_code,
|
||||
template_name: row.template_name,
|
||||
formula_type: row.formula_type,
|
||||
params: { ...row.params },
|
||||
description: row.description ?? '',
|
||||
sort_order: row.sort_order,
|
||||
})
|
||||
templateDialogVisible.value = true
|
||||
templateFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submitTemplate(): Promise<void> {
|
||||
const valid = await templateFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
templateSubmitting.value = true
|
||||
try {
|
||||
if (templateEditingId.value === null) {
|
||||
await businessRoleApi.createCalcTemplate({
|
||||
template_code: templateForm.template_code,
|
||||
template_name: templateForm.template_name,
|
||||
formula_type: templateForm.formula_type,
|
||||
params: templateForm.params,
|
||||
description: templateForm.description || undefined,
|
||||
sort_order: templateForm.sort_order,
|
||||
})
|
||||
ElMessage.success('模板已创建')
|
||||
} else {
|
||||
await businessRoleApi.updateCalcTemplate(templateEditingId.value, {
|
||||
template_name: templateForm.template_name,
|
||||
params: templateForm.params,
|
||||
description: templateForm.description || undefined,
|
||||
sort_order: templateForm.sort_order,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
}
|
||||
templateDialogVisible.value = false
|
||||
await Promise.all([loadTemplateManager(), loadTemplateOptions()])
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
templateSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(row: CalcTemplateItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除模板「${row.template_name}」?已被岗位引用时不能删除。`,
|
||||
title: '删除模板',
|
||||
confirmButtonText: '删除',
|
||||
successText: '模板已删除',
|
||||
run: () => businessRoleApi.deleteCalcTemplate(row.id),
|
||||
after: async () => {
|
||||
await Promise.all([loadTemplateManager(), loadTemplateOptions()])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
@ -210,9 +349,9 @@ onMounted(() => {
|
||||
<div class="page">
|
||||
<!-- 页面头:点明岗位是「公式模板 + 参数」的绑定关系,改它会牵动后续核算口径 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">岗位角色</h1>
|
||||
<h1 class="page-title">计提岗位</h1>
|
||||
<p class="page-desc">
|
||||
决定人员适用哪套计提公式与参数;新增岗位只需选模板,不用改代码,改动只影响之后的核算。
|
||||
用于人员岗位、计提公式和参数,不控制账号登录权限。账号能做什么,请在角色管理中配置。
|
||||
</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
@ -249,7 +388,8 @@ onMounted(() => {
|
||||
|
||||
<!-- 主操作行:新增岗位单独成行,与筛选动作分开 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增岗位</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('system:business_roles:create')" :title="permissionUser.permissionReason('system:business_roles:create')">新增岗位</el-button>
|
||||
<el-button @click="openTemplateManager">公式模板管理</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@ -284,15 +424,15 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">编辑</el-button>
|
||||
<span :title="builtinHint(row)">
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="isBuiltin(row)"
|
||||
:disabled="(isBuiltin(row)) || !permissionUser.can('system:business_roles:status')"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
:title="permissionUser.permissionReason('system:business_roles:status')">
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</span>
|
||||
@ -302,9 +442,9 @@ onMounted(() => {
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isBuiltin(row)"
|
||||
:disabled="(isBuiltin(row)) || !permissionUser.can('system:business_roles:delete')"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
:title="permissionUser.permissionReason('system:business_roles:delete')">
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
@ -358,6 +498,8 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item v-if="selectedTemplate" label-width="90px">
|
||||
<div class="tpl-info">
|
||||
<p>公式模板由系统内置,这里选择适用公式。比例、递减斜率和天数等数值在“计提参数”维护。</p>
|
||||
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params" target="_blank" rel="noopener">打开计提参数</router-link>
|
||||
<div>{{ selectedTemplate.description }}</div>
|
||||
<div v-if="selectedTemplate.params.length" class="tpl-params">
|
||||
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
|
||||
@ -373,7 +515,72 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 公式模板管理:模板入库配置,骨架受控,参数绑定指向计提参数编码 -->
|
||||
<el-dialog v-model="templateManagerVisible" title="公式模板管理" width="min(980px, 96vw)">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openTemplateCreate" :disabled="!permissionUser.can('system:business_roles:create')" :title="permissionUser.permissionReason('system:business_roles:create')">新增模板</el-button>
|
||||
<span class="toolbar-note">骨架决定计算方式,参数绑定指向“计提参数”中的编码;系统模板的绑定关系锁定。</span>
|
||||
</div>
|
||||
<el-table v-loading="templateLoading" :data="templates" stripe empty-text="暂无模板定义">
|
||||
<el-table-column prop="template_code" label="编码" min-width="180"><template #default="{ row }"><span class="cell-code">{{ row.template_code }}</span></template></el-table-column>
|
||||
<el-table-column prop="template_name" label="名称" min-width="160" />
|
||||
<el-table-column label="骨架" min-width="130"><template #default="{ row }">{{ formulaName(row.formula_type) }}</template></el-table-column>
|
||||
<el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${role}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="岗位引用" width="90" align="right"><template #default="{ row }"><span class="cell-num">{{ row.role_count }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openTemplateEdit(row)" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">编辑</el-button>
|
||||
<span :title="row.is_system === 1 ? '系统模板不可删除' : ''">
|
||||
<el-button link type="danger" size="small" :disabled="row.is_system === 1 || row.role_count > 0 || !permissionUser.can('system:business_roles:delete')" @click="deleteTemplate(row)" :title="permissionUser.permissionReason('system:business_roles:delete')">删除</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="templateManagerVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增 / 编辑模板 -->
|
||||
<el-dialog v-model="templateDialogVisible" :title="templateEditingId === null ? '新增公式模板' : '编辑公式模板'" width="560px" destroy-on-close>
|
||||
<el-form ref="templateFormRef" :model="templateForm" :rules="templateRules" label-width="96px">
|
||||
<el-form-item label="模板编码" prop="template_code">
|
||||
<el-input v-model="templateForm.template_code" :disabled="templateEditingId !== null" :placeholder="templateEditingId === null ? '如 TPL_SENIOR_DECAY,创建后不可修改' : ''" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板名称" prop="template_name">
|
||||
<el-input v-model="templateForm.template_name" placeholder="如 高级业务员·天数递减" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公式骨架" prop="formula_type">
|
||||
<el-select v-model="templateForm.formula_type" :disabled="templateEditingId !== null" placeholder="选择计算骨架" style="width: 100%">
|
||||
<el-option v-for="f in formulas" :key="f.code" :label="f.name" :value="f.code" />
|
||||
</el-select>
|
||||
<p v-if="selectedFormula" class="form-hint">{{ selectedFormula.description }}</p>
|
||||
</el-form-item>
|
||||
<template v-if="selectedFormula">
|
||||
<el-form-item v-for="role in selectedFormula.roles" :key="role" :label="`参数·${role}`">
|
||||
<el-select v-model="templateForm.params[role]" :disabled="editingTemplate?.is_system === 1" placeholder="选择计提参数" style="width: 100%" filterable>
|
||||
<el-option v-for="item in paramOptions" :key="item.code" :label="`${item.name}(${item.group})`" :value="item.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<p v-if="editingTemplate?.is_system === 1" class="form-hint">系统模板的参数绑定已锁定,仅可修改名称与说明。</p>
|
||||
</template>
|
||||
<el-form-item label="模板说明">
|
||||
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="选填,说明适用场景" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="templateForm.sort_order" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="templateSubmitting" @click="submitTemplate" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@ -175,7 +175,7 @@ watch(activeTab, (tab) => {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">日志查询</h1>
|
||||
<p class="page-desc">
|
||||
检索操作与登录痕迹,用于追责与排障;日志只读,超出保留期的记录由后端自动清理。
|
||||
查询操作与登录记录,核对操作人员、时间和处理结果。日志仅供查看。
|
||||
</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
|
||||
72
frontend/src/views/system/monitor/MonitorTrend.vue
Normal file
72
frontend/src/views/system/monitor/MonitorTrend.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { SVGRenderer } from 'echarts/renderers'
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||
import type { TrendPoint } from '@/api/monitor'
|
||||
|
||||
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, SVGRenderer])
|
||||
const props = defineProps<{ points: TrendPoint[]; mode: 'resources' | 'requests' | 'latency'; title: string }>()
|
||||
const emit = defineEmits<{ select: [at: string] }>()
|
||||
const element = ref<HTMLDivElement>()
|
||||
let chart: ECharts | undefined
|
||||
let observer: ResizeObserver | undefined
|
||||
const hasData = computed(() => props.points.some(point =>
|
||||
props.mode === 'resources' ? point.memory_percent != null : point.request_count != null))
|
||||
const font = "'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
|
||||
const names = computed(() => props.mode === 'resources' ? ['CPU', '内存', '磁盘'] : props.mode === 'requests' ? ['请求量', '5xx 错误率'] : ['P95 响应时间'])
|
||||
async function render(): Promise<void> {
|
||||
await nextTick()
|
||||
if (!element.value) return
|
||||
chart ??= echarts.init(element.value, undefined, { renderer: 'svg' })
|
||||
const fields: (keyof TrendPoint)[] = props.mode === 'resources' ? ['cpu_percent', 'memory_percent', 'disk_percent'] : props.mode === 'requests' ? ['request_count', 'error_rate'] : ['p95_ms']
|
||||
const option: EChartsCoreOption = {
|
||||
animation: false,
|
||||
textStyle: { fontFamily: font }, color: ['#2e4ba8', '#187e55', '#a16e16'],
|
||||
legend: { top: 0, right: 8, textStyle: { fontFamily: font, color: '#5b6577', fontSize: 12 } },
|
||||
grid: { left: 10, right: 18, top: 42, bottom: 8, containLabel: true },
|
||||
tooltip: { trigger: 'axis', renderMode: 'richText', textStyle: { fontFamily: font } },
|
||||
xAxis: { type: 'category', data: props.points.map(point => point.at), boundaryGap: false,
|
||||
axisLabel: { color: '#657086', formatter: (value: string) => props.points.length > 100 ? value.slice(5, 16).replace('T', ' ') : value.slice(11, 16) } },
|
||||
yAxis: [{ type: 'value', name: props.mode === 'resources' ? '%' : props.mode === 'latency' ? 'ms' : '次',
|
||||
max: props.mode === 'resources' ? 100 : undefined, axisLabel: { color: '#657086' },
|
||||
splitLine: { lineStyle: { color: '#edf0f5' } } },
|
||||
...(props.mode === 'requests' ? [{ type: 'value', name: '%', min: 0, max: 100, splitLine: { show: false }, axisLabel: { color: '#657086' } }] : [])],
|
||||
series: fields.map((field, index) => ({ name: names.value[index], type: 'line',
|
||||
data: props.points.map(point => point[field]), showSymbol: false, connectNulls: false,
|
||||
yAxisIndex: props.mode === 'requests' && index === 1 ? 1 : 0, lineStyle: { width: 2 } })),
|
||||
}
|
||||
chart.setOption(option, true)
|
||||
chart.getZr().off('click')
|
||||
chart.getZr().on('click', event => {
|
||||
if (!chart?.containPixel('grid', [event.offsetX, event.offsetY])) return
|
||||
const position = chart.convertFromPixel({ xAxisIndex: 0 }, event.offsetX)
|
||||
const point = props.points[Math.round(Number(position))]
|
||||
if (point) emit('select', point.at)
|
||||
})
|
||||
}
|
||||
watch(() => [props.points, props.mode], render)
|
||||
onMounted(() => {
|
||||
void render()
|
||||
observer = new ResizeObserver(() => chart?.resize())
|
||||
if (element.value) observer.observe(element.value)
|
||||
})
|
||||
onBeforeUnmount(() => { observer?.disconnect(); chart?.dispose() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="trend-panel" :aria-label="title">
|
||||
<h3>{{ title }}</h3>
|
||||
<div ref="element" class="trend-chart" role="img" :aria-label="`${title},按分钟聚合,断线代表缺少采集数据`" />
|
||||
<p v-if="!hasData" class="chart-empty">尚无采集数据,请稍后刷新。历史缺口不会补造。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trend-panel { position: relative; min-width: 0; padding: 20px; }
|
||||
h3 { margin: 0 0 16px; font-size: 15px; font-weight: 600; }
|
||||
.trend-chart { height: 230px; width: 100%; }
|
||||
.chart-empty { position: absolute; inset: 125px 20px auto; text-align: center; color: var(--ys-ink-2); background: var(--ys-paper); padding: 12px; }
|
||||
</style>
|
||||
249
frontend/src/views/system/monitor/index.vue
Normal file
249
frontend/src/views/system/monitor/index.vue
Normal file
@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CircleCheck, Refresh, Download, CopyDocument, ArrowRight, Warning } from '@element-plus/icons-vue'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { kindLabels, number, phases, statusLabels, statusTypes, uptime } from './labels'
|
||||
import MonitorTrend from './MonitorTrend.vue'
|
||||
import { useMonitor } from './useMonitor'
|
||||
|
||||
const m = useMonitor(), router = useRouter()
|
||||
const status = computed(() => m.failed ? 'unknown' : m.overview?.status ?? 'unknown')
|
||||
const resources = computed(() => m.overview?.resources ?? {})
|
||||
const tasks = computed(() => m.overview?.tasks)
|
||||
const metrics = computed(() => [
|
||||
{ label: '系统 CPU', value: number(resources.value.cpu_percent, '%'), note: '可见系统资源' },
|
||||
{ label: '系统内存', value: number(resources.value.memory_percent, '%'), note: '可见系统资源' },
|
||||
{ label: '应用数据盘', value: number(resources.value.disk_percent, '%'), note: `剩余 ${number(resources.value.disk_free_gb, ' GB')}` },
|
||||
{ label: '请求量', value: number(m.overview?.requests.count), note: '近 5 个分钟桶' },
|
||||
{ label: '5xx 错误率', value: number(m.overview?.requests.error_rate, '%'), note: '4xx 不计系统故障' },
|
||||
{ label: 'P95 响应时间', value: m.overview?.requests.p95_ms === null ? '暂无' : number(m.overview?.requests.p95_ms, ' ms'), note: '直方图近似值' },
|
||||
])
|
||||
function openTask(batch?: string | null): void {
|
||||
void router.push({ path: '/system/tplus', query: batch ? { batch } : {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<header class="page-head monitor-head">
|
||||
<div>
|
||||
<h1 class="page-title">系统监控</h1>
|
||||
<p class="page-desc">观察运行状态,关联异常请求,追查问题发生的原因。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="refresh-label">每 15 秒刷新</span>
|
||||
<el-switch v-model="m.autoRefresh" aria-label="自动刷新监控" />
|
||||
<el-button :icon="Refresh" :loading="m.loading" @click="m.refresh">刷新</el-button>
|
||||
<el-button :icon="Download" :loading="m.downloading" :disabled="!m.overview || m.failed" @click="m.download">诊断摘要</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="m.failed" class="monitor-notice" type="error" :closable="false" show-icon
|
||||
title="监控连接失败,请检查服务或稍后刷新。">
|
||||
<span>最后成功连接:{{ formatDateTime(m.lastConnected, '尚未连接成功') }}。下方保留的数据不代表当前状态。</span>
|
||||
</el-alert>
|
||||
<el-alert v-if="m.overview && m.overview.collection_status !== 'normal'" class="monitor-notice"
|
||||
type="warning" :closable="false" show-icon :title="m.overview.collection_message" />
|
||||
<el-alert v-if="m.overview?.dropped_events" class="monitor-notice" type="warning" :closable="false"
|
||||
:title="`监控缓冲已丢弃 ${m.overview.dropped_events} 条事件,历史可能不完整,请结合服务端日志排查。`" />
|
||||
|
||||
<section class="runtime-line" aria-label="当前运行信息">
|
||||
<el-tag :type="statusTypes[status]" effect="light">{{ statusLabels[status] }}</el-tag>
|
||||
<span>环境 <b>{{ m.overview?.environment ?? '—' }}</b></span>
|
||||
<span>版本 <b>{{ m.overview?.version ?? '—' }}</b></span>
|
||||
<span>运行 <b>{{ m.overview ? uptime(m.overview.uptime_seconds) : '—' }}</b></span>
|
||||
<span class="collected">最后采集 {{ formatDateTime(m.overview?.collected_at, '等待首次采集') }}</span>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="m.tab" class="monitor-tabs" @tab-change="m.loadContent">
|
||||
<el-tab-pane label="健康总览" name="overview" />
|
||||
<el-tab-pane label="接口性能" name="interfaces" />
|
||||
<el-tab-pane label="异常排查" name="events" />
|
||||
<el-tab-pane label="任务与同步" name="tasks" />
|
||||
</el-tabs>
|
||||
|
||||
<template v-if="m.tab === 'overview'">
|
||||
<section class="card incident-panel" aria-label="当前异常摘要" :aria-busy="m.loading">
|
||||
<div class="section-head"><h2>当前异常 <span class="count">{{ m.overview?.alerts.length ?? '—' }}</span></h2><span class="hint">同类健康问题合并展示,恢复过程留痕</span></div>
|
||||
<div v-if="!m.overview" class="state-placeholder">{{ m.failed ? '暂时无法获取运行状态,请点击刷新重试。' : '正在获取首份运行状态…' }}</div>
|
||||
<div v-else-if="!m.overview.alerts.length" class="healthy-line">
|
||||
<el-icon :size="22"><CircleCheck v-if="status === 'normal'" /><Warning v-else /></el-icon>
|
||||
<div><strong>{{ status === 'normal' ? '当前未发现活动异常' : '当前状态尚不能确认' }}</strong><p>最近采集 {{ formatDateTime(m.overview.collected_at) }},历史记录保留 7 天。</p></div>
|
||||
</div>
|
||||
<div v-for="alert in m.overview?.alerts" :key="alert.code" class="incident-row">
|
||||
<el-tag :type="alert.level === 'error' ? 'danger' : 'warning'">{{ alert.level === 'error' ? '异常' : '关注' }}</el-tag>
|
||||
<div class="incident-copy"><strong>{{ alert.message }}</strong><p>{{ alert.code }} · 首次 {{ formatDateTime(alert.first_seen) }} · 最近 {{ formatDateTime(alert.last_seen) }} · 持续采样 {{ alert.occurrences }} 次</p></div>
|
||||
<el-button link type="primary" @click="m.openEvent(alert)">查看详情<el-icon><ArrowRight /></el-icon></el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-strip" aria-label="关键运行指标">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="metric-cell"><span>{{ metric.label }}</span><strong>{{ metric.value }}</strong><small>{{ metric.note }}</small></div>
|
||||
</section>
|
||||
<p class="resource-note">{{ resources.scope ?? '等待资源采集' }}。应用进程:CPU {{ number(resources.process_cpu_percent, '%') }},内存 {{ number(resources.process_memory_mb, ' MB') }};CPU 按可见总量折算。{{ m.failed ? '当前显示上次成功数据。' : '' }}</p>
|
||||
|
||||
<section class="card dependencies" aria-label="依赖健康">
|
||||
<div class="section-head"><h2>依赖健康</h2><span class="hint">连接检查不执行写入或 T+ 源库扫描</span></div>
|
||||
<el-table :data="m.overview?.dependencies ?? []" class="data-table" empty-text="等待首次依赖采集">
|
||||
<el-table-column prop="name" label="组件" min-width="125" />
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="statusTypes[m.failed ? 'unknown' : row.status as keyof typeof statusTypes]">{{ statusLabels[m.failed ? 'unknown' : row.status as keyof typeof statusLabels] }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="message" label="检查结果" min-width="250" />
|
||||
<el-table-column label="检查耗时" width="130"><template #default="{ row }">{{ number(row.duration_ms, ' ms') }}</template></el-table-column>
|
||||
<el-table-column label="检查时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.checked_at) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-if="m.tab !== 'tasks'" class="window-toolbar" aria-label="监控时间范围">
|
||||
<el-radio-group v-model="m.hours" aria-label="历史时间范围" @change="m.search">
|
||||
<el-radio-button :value="1">最近 1 小时</el-radio-button><el-radio-button :value="24">24 小时</el-radio-button><el-radio-button :value="168">7 天</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-date-picker v-model="m.end" type="datetime" placeholder="截至当前,可选择历史时间" aria-label="历史截止时间" value-format="YYYY-MM-DDTHH:mm:ss" @change="m.search" />
|
||||
<el-button v-if="m.isHistorical" link type="primary" @click="m.live">返回实时</el-button>
|
||||
<span class="hint">北京时间 · 分钟聚合{{ m.isHistorical ? ' · 历史查看中' : '' }}</span>
|
||||
</section>
|
||||
<el-alert v-if="m.contentFailed" class="monitor-notice" type="error" :closable="false" title="当前视图加载失败,以下旧数据可能已过期,请重试。"><el-button link type="primary" @click="m.loadContent">重试加载</el-button></el-alert>
|
||||
|
||||
<section v-if="m.tab === 'overview'" v-loading="m.contentLoading" class="card trends" aria-label="运行趋势">
|
||||
<div class="section-head"><h2>运行趋势</h2><span class="hint">断线代表缺少采集;点击曲线数据点追查该时段事件</span></div>
|
||||
<div class="trend-grid"><MonitorTrend :points="m.trends?.points ?? []" mode="resources" title="资源占用" @select="m.inspectTime" /><MonitorTrend :points="m.trends?.points ?? []" mode="requests" title="请求量与错误率" @select="m.inspectTime" /></div>
|
||||
<MonitorTrend :points="m.trends?.points ?? []" mode="latency" title="P95 响应时间(近似值)" @select="m.inspectTime" />
|
||||
</section>
|
||||
|
||||
<template v-if="m.tab === 'interfaces'">
|
||||
<div class="filter-bar"><el-input v-model="m.route" clearable placeholder="输入完整路由模板,如 /api/v1/base/products/{id}" aria-label="接口路由模板" @keyup.enter="m.search" /><el-button type="primary" @click="m.search">查询接口</el-button></div>
|
||||
<el-alert v-if="m.truncated" class="monitor-notice" type="warning" :closable="false" title="记录较多,当前结果已截断,请缩短时间范围或指定接口后查询。" />
|
||||
<section class="card" aria-label="接口性能排行">
|
||||
<div class="section-head"><h2>慢接口排行</h2><span class="hint">按 P95 降序 · 4xx 单独统计 · 监控请求不参与</span></div>
|
||||
<el-table v-loading="m.contentLoading" :data="m.interfaces" empty-text="所选时段暂无接口请求" class="data-table">
|
||||
<el-table-column label="接口" min-width="270"><template #default="{ row }"><span class="method">{{ row.method }}</span><span class="route-text">{{ row.route }}</span></template></el-table-column>
|
||||
<el-table-column prop="count" label="请求数" width="95" align="right" />
|
||||
<el-table-column prop="errors_5xx" label="5xx" width="80" align="right" />
|
||||
<el-table-column prop="errors_4xx" label="4xx" width="80" align="right" />
|
||||
<el-table-column label="P95(近似)" width="135" align="right"><template #default="{ row }">{{ number(row.p95_ms, ' ms') }}</template></el-table-column>
|
||||
<el-table-column label="最大耗时" width="120" align="right"><template #default="{ row }">{{ number(row.max_ms, ' ms') }}</template></el-table-column>
|
||||
<el-table-column label="最后 5xx" min-width="175"><template #default="{ row }">{{ formatDateTime(row.last_error_at) }}</template></el-table-column>
|
||||
<el-table-column label="追查" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="m.inspectRoute(row)">查看异常</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="m.tab === 'events'">
|
||||
<div class="filter-bar event-filters">
|
||||
<el-select v-model="m.kind" clearable placeholder="全部事件类型" aria-label="事件类型"><el-option v-for="(label, key) in kindLabels" :key="key" :label="label" :value="key" /></el-select>
|
||||
<el-input v-model="m.requestId" clearable :maxlength="32" placeholder="请求编号" aria-label="请求编号" @keyup.enter="m.search" />
|
||||
<el-input v-model="m.route" clearable placeholder="接口路由模板" aria-label="事件路由模板" @keyup.enter="m.search" />
|
||||
<el-button type="primary" @click="m.search">查询事件</el-button>
|
||||
</div>
|
||||
<section class="card" aria-label="诊断事件列表">
|
||||
<div class="section-head"><h2>诊断事件</h2><span class="hint">错误原因保存在服务端日志,请使用请求编号关联</span></div>
|
||||
<el-table v-loading="m.contentLoading" :data="m.events" class="data-table" empty-text="所选条件下暂无诊断事件">
|
||||
<el-table-column label="最近发生" min-width="170"><template #default="{ row }">{{ formatDateTime(row.last_seen) }}</template></el-table-column>
|
||||
<el-table-column label="类型 / 状态" min-width="145"><template #default="{ row }">{{ kindLabels[row.kind] }}<p v-if="row.kind === 'HEALTH'" class="secondary"><el-tag :type="row.resolved_at ? 'success' : 'warning'" size="small">{{ row.resolved_at ? '已恢复' : '持续中' }}</el-tag></p></template></el-table-column>
|
||||
<el-table-column label="说明 / 错误码" min-width="290"><template #default="{ row }">{{ row.message }}<p class="secondary">{{ row.code }} · {{ row.component }}</p></template></el-table-column>
|
||||
<el-table-column label="接口 / 请求编号" min-width="260"><template #default="{ row }"><span class="route-text">{{ row.method }} {{ row.route ?? '—' }}</span><p v-if="row.request_id" class="secondary request-code">{{ row.request_id }}</p></template></el-table-column>
|
||||
<el-table-column label="耗时" width="110"><template #default="{ row }">{{ number(row.duration_ms, ' ms') }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="m.openEvent(row)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="m.total" :page-num="m.pageNum" :page-size="m.pageSize" @page-change="m.pageNum = $event; m.loadContent()" @size-change="m.pageSize = $event; m.search()" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-if="m.tab === 'tasks'" class="card task-panel" aria-label="T+ 任务健康">
|
||||
<div class="section-head"><h2>T+ 任务与同步</h2><el-button type="primary" @click="openTask()">打开 T+ 数据同步<el-icon><ArrowRight /></el-icon></el-button></div>
|
||||
<el-alert :title="tasks?.message ?? '等待首次任务状态采集。'" :type="tasks?.status === 'warning' ? 'warning' : 'info'" :closable="false" />
|
||||
<dl class="task-grid">
|
||||
<div><dt>自动采集</dt><dd>{{ tasks?.enabled ? tasks.stop_requested ? '已请求停止' : '已启用' : '未启用' }}</dd></div>
|
||||
<div><dt>进程状态</dt><dd>{{ phases[tasks?.worker_phase ?? 'UNKNOWN'] ?? '未知' }}</dd></div>
|
||||
<div><dt>最近心跳</dt><dd>{{ formatDateTime(tasks?.heartbeat_at) }}</dd></div>
|
||||
<div><dt>最近成功采集</dt><dd>{{ formatDateTime(tasks?.last_success_at) }}</dd></div>
|
||||
<div><dt>下次有限重试</dt><dd>{{ formatDateTime(tasks?.next_retry_at) }}</dd></div>
|
||||
</dl>
|
||||
<div v-for="alert in tasks?.alerts" :key="alert.code" class="incident-row"><el-tag type="warning">关注</el-tag><div class="incident-copy"><strong>{{ alert.message }}</strong><p>{{ alert.code }}</p></div><el-button v-if="alert.batch_no" link type="primary" @click="openTask(alert.batch_no)">查看相关批次</el-button></div>
|
||||
<p class="hint">未配置或主动停用不会标记为宕机。任务创建、重试和停止在原 T+ 页面操作。</p>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="m.drawer" title="诊断事件详情" size="min(620px, 100vw)" destroy-on-close>
|
||||
<div v-loading="m.detailLoading" class="event-detail">
|
||||
<el-alert v-if="m.detailFailed" type="error" :closable="false" title="详情加载失败,当前保留列表摘要。" />
|
||||
<template v-if="m.detail">
|
||||
<el-tag :type="m.detail.resolved_at ? 'success' : m.detail.level === 'error' ? 'danger' : 'warning'">{{ m.detail.resolved_at ? '已恢复' : kindLabels[m.detail.kind] }}</el-tag>
|
||||
<h2>{{ m.detail.message }}</h2>
|
||||
<dl class="detail-fields">
|
||||
<div><dt>错误码</dt><dd>{{ m.detail.code }}</dd></div><div><dt>所属模块</dt><dd>{{ m.detail.component }}</dd></div>
|
||||
<div><dt>首次发生</dt><dd>{{ formatDateTime(m.detail.first_seen) }}</dd></div><div><dt>最近发生</dt><dd>{{ formatDateTime(m.detail.last_seen) }}</dd></div>
|
||||
<div><dt>发生 / 采样次数</dt><dd>{{ m.detail.occurrences }}</dd></div><div><dt>恢复时间</dt><dd>{{ formatDateTime(m.detail.resolved_at) }}</dd></div>
|
||||
<div><dt>请求接口</dt><dd>{{ m.detail.method }} {{ m.detail.route ?? '—' }}</dd></div><div><dt>状态码 / 耗时</dt><dd>{{ m.detail.status_code ?? '—' }} / {{ number(m.detail.duration_ms, ' ms') }}</dd></div>
|
||||
<div><dt>应用实例</dt><dd>{{ m.detail.instance_id }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="m.detail.request_id" class="request-block"><span>请求编号</span><code>{{ m.detail.request_id }}</code><el-button :icon="CopyDocument" @click="m.copy(m.detail!.request_id!)">复制请求编号</el-button></div>
|
||||
<el-button v-if="m.detail.batch_no" type="primary" @click="openTask(m.detail.batch_no)">查看批次 {{ m.detail.batch_no }}</el-button>
|
||||
<section class="runbook"><h3>排查建议</h3><ol><li>核对同一时段的资源趋势与依赖状态。</li><li>接口异常使用请求编号检索服务端日志;任务异常查看关联批次。</li><li>确认实际影响和异常原因后,再到对应功能处理。</li></ol><p>页面仅提供运行摘要,完整异常堆栈保留在受控服务端日志。</p></section>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.monitor-page { min-width: 0; padding-bottom: 24px; }
|
||||
.monitor-head { flex-wrap: wrap; gap: 16px; }
|
||||
.toolbar, .window-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.toolbar :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.refresh-label, .hint, .resource-note, .secondary { font-size: 12px; color: var(--ys-ink-2); }
|
||||
.monitor-notice { margin-bottom: 16px; }
|
||||
.runtime-line { display: flex; gap: 24px; align-items: center; flex-wrap: wrap; padding: 16px 0 22px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.runtime-line b { margin-left: 6px; color: var(--ys-ink); font-weight: 500; }
|
||||
.collected { margin-left: auto; }
|
||||
.monitor-tabs { margin-bottom: 8px; }
|
||||
.section-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; padding: 20px; }
|
||||
.section-head h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.count { display: inline-block; margin-left: 8px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.incident-panel { margin-bottom: 24px; }
|
||||
.healthy-line { display: flex; align-items: center; gap: 14px; padding: 4px 20px 24px; }
|
||||
.healthy-line .el-icon { color: var(--ys-ink-2); }
|
||||
.healthy-line strong, .incident-copy strong { font-weight: 500; font-size: 14px; }
|
||||
.healthy-line p, .incident-copy p { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||
.incident-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid var(--ys-line); }
|
||||
.incident-copy { flex: 1; min-width: 0; }
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); background: var(--ys-paper); border: 1px solid var(--ys-line); border-radius: var(--ys-radius); }
|
||||
.metric-cell { display: flex; flex-direction: column; gap: 10px; padding: 20px; border-right: 1px solid var(--ys-line); font-variant-numeric: tabular-nums; }
|
||||
.metric-cell:last-child { border: none; }
|
||||
.metric-cell > span { font-size: 13px; color: var(--ys-ink-2); }
|
||||
.metric-cell strong { font-size: 25px; font-weight: 600; white-space: nowrap; }
|
||||
.metric-cell small { font-size: 11px; color: var(--ys-ink-2); }
|
||||
.resource-note { margin: 12px 0 24px; line-height: 1.8; }
|
||||
.dependencies { margin-bottom: 24px; }
|
||||
.window-toolbar { padding: 16px 0 20px; }
|
||||
.window-toolbar :deep(.el-date-editor) { width: 270px; }
|
||||
.trend-grid { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.trend-grid > :first-child { border-right: 1px solid var(--ys-line); }
|
||||
.trend-grid + * { border-top: 1px solid var(--ys-line); }
|
||||
.state-placeholder { padding: 20px; color: var(--ys-ink-2); }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
.filter-bar > .el-input { width: 460px; max-width: 100%; }
|
||||
.event-filters > .el-input { width: 260px; }
|
||||
.event-filters > .el-select { width: 160px; }
|
||||
.method { margin-right: 10px; font-size: 11px; color: var(--ys-ink-2); font-weight: 600; }
|
||||
.route-text { overflow-wrap: anywhere; font-size: 12px; }
|
||||
.secondary { margin: 5px 0 0; }
|
||||
.request-code { font-family: var(--ys-font-mono); overflow-wrap: anywhere; }
|
||||
.task-panel { padding-bottom: 20px; }
|
||||
.task-panel > .el-alert, .task-panel > p { margin: 0 20px; width: auto; }
|
||||
.task-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; padding: 28px 20px; margin: 0; }
|
||||
dt { color: var(--ys-ink-2); font-size: 12px; }
|
||||
dd { margin: 8px 0 0; font-size: 14px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.event-detail h2 { font-size: 18px; line-height: 1.7; margin: 18px 0 24px; }
|
||||
.detail-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.request-block { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; padding: 20px; background: var(--ys-mist); border-radius: var(--ys-radius-sm); margin: 24px 0; font-size: 13px; }
|
||||
.request-block code { overflow-wrap: anywhere; max-width: 100%; }
|
||||
.runbook { margin-top: 32px; border-top: 1px solid var(--ys-line); padding-top: 16px; }
|
||||
.runbook h3 { font-size: 15px; }
|
||||
.runbook ol { padding-left: 20px; line-height: 2; font-size: 13px; }
|
||||
.runbook p { font-size: 12px; color: var(--ys-ink-2); line-height: 1.8; }
|
||||
@media (max-width: 1400px) { .metric-cell { padding: 16px 14px; } .metric-cell strong { font-size: 22px; } }
|
||||
@media (max-width: 1000px) { .metric-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } .metric-cell:nth-child(3) { border-right: none; } .metric-cell:nth-child(-n+3) { border-bottom: 1px solid var(--ys-line); } .collected { margin-left: 0; } .trend-grid { grid-template-columns: 1fr; } .trend-grid > :first-child { border-right: 0; border-bottom: 1px solid var(--ys-line); } }
|
||||
@media (max-width: 600px) { .metric-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric-cell:nth-child(3) { border-right: 1px solid var(--ys-line); } .metric-cell:nth-child(2n) { border-right: 0; } .metric-cell:nth-child(4) { border-bottom: 1px solid var(--ys-line); } .runtime-line { gap: 12px; } .task-grid, .detail-fields { grid-template-columns: 1fr; } .section-head .hint { line-height: 1.7; } .incident-row { align-items: flex-start; flex-wrap: wrap; } .window-toolbar :deep(.el-date-editor) { max-width: 100%; } }
|
||||
</style>
|
||||
12
frontend/src/views/system/monitor/labels.ts
Normal file
12
frontend/src/views/system/monitor/labels.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { MonitorStatus } from '@/api/monitor'
|
||||
|
||||
export const statusLabels: Record<MonitorStatus, string> = { normal: '正常', warning: '关注', error: '异常', unknown: '未知' }
|
||||
export const statusTypes = { normal: 'success', warning: 'warning', error: 'danger', unknown: 'info' } as const
|
||||
export const kindLabels: Record<string, string> = { HEALTH: '健康异常', REQUEST_ERROR: '接口异常', SLOW_REQUEST: '慢请求', RUNTIME_ERROR: '后台异常' }
|
||||
export const phases: Record<string, string> = { 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)} 分钟`
|
||||
}
|
||||
124
frontend/src/views/system/monitor/useMonitor.ts
Normal file
124
frontend/src/views/system/monitor/useMonitor.ts
Normal file
@ -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<api.MonitorOverview | null>(null)
|
||||
const loading = ref(false), failed = ref(false), autoRefresh = ref(true)
|
||||
const lastConnected = ref<string | null>(null)
|
||||
const trends = ref<api.Trends | null>(null)
|
||||
const interfaces = ref<api.InterfaceMetric[]>([]), truncated = ref(false)
|
||||
const events = ref<api.MonitorEvent[]>([]), total = ref(0), detail = ref<api.MonitorEvent | null>(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<string | null>(null)
|
||||
const route = ref(''), kind = ref(''), requestId = ref(''), pageNum = ref(1), pageSize = ref(20)
|
||||
const isHistorical = computed(() => Boolean(end.value))
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let disposed = false, contentVersion = 0, detailVersion = 0
|
||||
const controllers = new Set<AbortController>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> { 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<void> {
|
||||
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<void> {
|
||||
try { await navigator.clipboard.writeText(value); ElMessage.success('请求编号已复制') }
|
||||
catch (error) { handledError(error); ElMessage.warning('复制失败,请选中编号手动复制。') }
|
||||
}
|
||||
async function download(): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import CoefficientPanel from '@/components/coefficients/CoefficientPanel.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type InputInstance, type SelectInstance } from 'element-plus'
|
||||
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import * as paramsApi from '@/api/params'
|
||||
@ -16,7 +21,7 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ROLE_ADMIN } from '@/utils/constants'
|
||||
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@ -32,12 +37,13 @@ import { handledError } from '@/utils/error'
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
||||
const isAdmin = computed(() => userStore.can('system:params:update'))
|
||||
|
||||
// ==================== 目录 ====================
|
||||
|
||||
const catalog = ref<ParamCatalogItem[]>([])
|
||||
const catalogLoading = ref(false)
|
||||
const expandedGroups = ref(['业务员个人户'])
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
catalogLoading.value = true
|
||||
@ -58,6 +64,9 @@ async function loadCatalog(): Promise<void> {
|
||||
const groupedCatalog = computed(() => {
|
||||
const groups = new Map<string, ParamCatalogItem[]>()
|
||||
for (const item of catalog.value) {
|
||||
// “产品系数表”按产品类别逐项维护,已由页面上方的系数表覆盖,
|
||||
// 这里不再重复展示,避免同一参数出现两个编辑入口。
|
||||
if (item.param_group === '产品系数表') continue
|
||||
groups.set(item.param_group, [...(groups.get(item.param_group) ?? []), item])
|
||||
}
|
||||
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
||||
@ -90,8 +99,8 @@ const ENUM_VALUE_LABELS: Record<string, string> = {
|
||||
INCLUDE: '含基本工资',
|
||||
DEDUCT: '扣除基本工资',
|
||||
EXCLUDE: '不计基本工资',
|
||||
'0': '关',
|
||||
'1': '开',
|
||||
'0': '关闭',
|
||||
'1': '开启',
|
||||
}
|
||||
|
||||
function valueLabel(value: string | null | undefined): string {
|
||||
@ -105,7 +114,8 @@ function formatValue(item: ParamCatalogItem, value: string): string {
|
||||
// 数字型(比例、天数、金额)保持原样,只补单位
|
||||
const shown =
|
||||
item.value_type === 'ENUM' || item.value_type === 'BOOL' ? valueLabel(value) : value
|
||||
return item.unit ? `${shown} ${item.unit}` : shown
|
||||
if (!item.unit) return shown
|
||||
return /^[%‰]/.test(item.unit) ? `${shown}${item.unit}` : `${shown} ${item.unit}`
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,6 +152,16 @@ function pendingOf(item: ParamCatalogItem, dimension: string): PendingChange | u
|
||||
const editVisible = ref(false)
|
||||
const editTarget = ref<{ item: ParamCatalogItem; dimension: string } | null>(null)
|
||||
const editValue = ref('')
|
||||
const editError = ref('')
|
||||
const editControl = ref<InputInstance | SelectInstance>()
|
||||
const pendingCard = ref<HTMLElement | null>(null)
|
||||
const editDimensionLabel = computed(() =>
|
||||
editTarget.value?.item.dimension_type === 'PRODUCT_CATEGORY' ? '产品类别' : '适用岗位',
|
||||
)
|
||||
const editPlaceholder = computed(() =>
|
||||
editTarget.value?.item.value_type === 'INT' ? '请输入整数' : '请输入数值,也可填写分数,如 4/360',
|
||||
)
|
||||
const approvalEnabled = computed(() => userStore.userInfo?.payroll_approval_enabled === true)
|
||||
|
||||
/** 可选维度:该参数已有维度值(无则视为默认) */
|
||||
const editDimensions = computed(() => {
|
||||
@ -157,21 +177,34 @@ function currentValue(item: ParamCatalogItem, dimension: string): string | undef
|
||||
|
||||
function openEdit(item: ParamCatalogItem, dimension: string): void {
|
||||
editTarget.value = { item, dimension }
|
||||
editValue.value = currentValue(item, dimension) ?? item.default_value ?? ''
|
||||
syncEditValue()
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function syncEditValue(): void {
|
||||
if (!editTarget.value) return
|
||||
const { item, dimension } = editTarget.value
|
||||
editValue.value = pendingOf(item, dimension)?.new_value ?? currentValue(item, dimension) ?? item.default_value ?? ''
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
watch(() => editTarget.value?.dimension, syncEditValue)
|
||||
|
||||
function showPending(): void {
|
||||
pendingCard.value?.scrollIntoView({ behavior: 'auto', block: 'start' })
|
||||
}
|
||||
|
||||
function confirmEdit(): void {
|
||||
if (!editTarget.value) return
|
||||
const { item, dimension } = editTarget.value
|
||||
const value = editValue.value.trim()
|
||||
if (!value) {
|
||||
ElMessage.warning('请输入新值')
|
||||
editError.value = '请填写调整后的参数值'
|
||||
return
|
||||
}
|
||||
const old = currentValue(item, dimension) ?? item.default_value ?? ''
|
||||
if (value === old) {
|
||||
ElMessage.info('新值与当前值相同,未加入待提交')
|
||||
ElMessage.info('参数值未变化,无需暂存')
|
||||
return
|
||||
}
|
||||
pending[pendingKey(item.param_code, dimension)] = {
|
||||
@ -181,7 +214,7 @@ function confirmEdit(): void {
|
||||
new_value: value,
|
||||
}
|
||||
editVisible.value = false
|
||||
ElMessage.success('已加入待提交,提交前不会生效')
|
||||
ElMessage.success('已暂存修改,请在“待提交变更”中确认生效日期并提交')
|
||||
}
|
||||
|
||||
function removePending(key: string): void {
|
||||
@ -206,9 +239,9 @@ async function submitAll(): Promise<void> {
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`共 ${pendingList.value.length} 项变更,${effectiveText.value}。提交后逐项留痕,未生效前可整批撤回。`,
|
||||
'提交确认',
|
||||
{ confirmButtonText: '提交', cancelButtonText: '再想想', customClass: 'ys-confirm-box' },
|
||||
`本次提交 ${pendingList.value.length} 项参数变更,${effectiveText.value}。原值和修改记录会保留;尚未生效的变更可在版本管理中整批撤回。`,
|
||||
'确认提交参数变更',
|
||||
{ confirmButtonText: '提交变更', cancelButtonText: '返回检查', customClass: 'ys-confirm-box' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
@ -400,7 +433,7 @@ const CUSTOMER_OPTIONS = [
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
|
||||
const page = await optionPage<Awaited<ReturnType<typeof businessRoleApi.listBusinessRoles>>['list'][number]>('business_roles')
|
||||
roleOptions.value = page.list
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
@ -446,12 +479,14 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirements-surface">
|
||||
<CoefficientPanel />
|
||||
<div class="page">
|
||||
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">计提参数</h1>
|
||||
<p class="page-desc">
|
||||
计提比例、系数、阈值、分成与起付比例都在这里统一维护;每次改动留下旧值,可以指定从哪天起生效。
|
||||
维护绩效计算规则与产品系数,查看当前生效值和变更历史。修改先暂存,提交时可选择生效日期。
|
||||
</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
@ -461,22 +496,27 @@ onMounted(() => {
|
||||
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openPreviewDrawer">计算预览</el-button>
|
||||
<el-button type="primary" @click="openPreviewDrawer" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">计算预览</el-button>
|
||||
<el-button @click="openVersionDrawer">版本管理</el-button>
|
||||
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
|
||||
<el-button @click="doExport" :loading="exporting" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
||||
<el-button v-if="pendingList.length" @click="showPending">待提交变更({{ pendingList.length }})</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<el-collapse v-loading="catalogLoading">
|
||||
<el-collapse v-model="expandedGroups" v-loading="catalogLoading">
|
||||
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
|
||||
<template #title>
|
||||
<span class="group-title">{{ group.name }}</span>
|
||||
</template>
|
||||
<p v-if="group.name === '审批开关'" class="approval-notice">
|
||||
{{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }}
|
||||
</p>
|
||||
<div class="param-table-wrap">
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 190px">参数</th>
|
||||
<th class="param-definition">参数与说明</th>
|
||||
<th>当前生效值</th>
|
||||
<th style="width: 110px">操作</th>
|
||||
</tr>
|
||||
@ -507,8 +547,8 @@ onMounted(() => {
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openEdit(item, v.dimension_value)"
|
||||
>
|
||||
改
|
||||
:disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@ -523,32 +563,33 @@ onMounted(() => {
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openEdit(item, '')"
|
||||
>
|
||||
:disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">
|
||||
设置
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
|
||||
<el-button link type="primary" size="small" @click="openHistory(item)">变更历史</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
|
||||
<div v-if="isAdmin && pendingList.length" class="card pending-card">
|
||||
<div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card">
|
||||
<div class="card-head">
|
||||
<div class="pending-head">
|
||||
<b>待提交变更({{ pendingList.length }} 项)</b>
|
||||
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
|
||||
<span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="pendingList" size="small">
|
||||
<el-table-column label="参数" min-width="160">
|
||||
<template #default="{ row }">{{ row.item.param_name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="维度" width="110">
|
||||
<el-table-column label="适用范围" width="110">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="110">
|
||||
@ -583,22 +624,22 @@ onMounted(() => {
|
||||
/>
|
||||
<el-input
|
||||
v-model="remark"
|
||||
placeholder="变更说明(如:四季度系数调整)"
|
||||
placeholder="变更说明(选填,如:调整第四季度计提比例)"
|
||||
style="width: 240px"
|
||||
maxlength="255"
|
||||
/>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAll" :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">提交变更</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改参数 -->
|
||||
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" title="修改参数" width="520px" destroy-on-close @opened="editControl?.focus()">
|
||||
<template v-if="editTarget">
|
||||
<div class="edit-name">{{ editTarget.item.param_name }}</div>
|
||||
<div class="edit-desc">{{ editTarget.item.description || editTarget.item.param_code }}</div>
|
||||
<el-form label-width="80px" style="margin-top: 16px" @submit.prevent>
|
||||
<el-form-item v-if="editDimensions.length > 1 || editTarget.item.dimension_type !== 'NONE'" label="维度">
|
||||
<el-form label-width="96px" class="param-edit-form" @submit.prevent>
|
||||
<el-form-item v-if="editDimensions.length > 1 || editTarget.item.dimension_type !== 'NONE'" :label="editDimensionLabel">
|
||||
<el-select v-model="editTarget.dimension" style="width: 100%">
|
||||
<el-option
|
||||
v-for="dim in editDimensions"
|
||||
@ -608,19 +649,20 @@ onMounted(() => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前值">
|
||||
<el-form-item label="当前生效值">
|
||||
<!-- 当前值同目录卡口径:枚举翻中文、数字补单位;编辑框里存的仍是原始值 -->
|
||||
<span class="num">
|
||||
{{ formatByCode(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value) }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新值">
|
||||
<el-form-item label="调整为" :error="editError">
|
||||
<!--
|
||||
枚举与开关都给下拉:让业务人员在「含基本工资」「开」里选,
|
||||
而不是自己敲 INCLUDE / 1 —— 提交给后端的仍是原始值(v-model 绑的是 value)
|
||||
-->
|
||||
<el-select
|
||||
v-if="editTarget.item.value_type === 'ENUM'"
|
||||
ref="editControl"
|
||||
v-model="editValue"
|
||||
style="width: 100%"
|
||||
>
|
||||
@ -634,29 +676,32 @@ onMounted(() => {
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else-if="editTarget.item.value_type === 'BOOL'"
|
||||
ref="editControl"
|
||||
v-model="editValue"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="开启" value="1" />
|
||||
<el-option label="关闭" value="0" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
||||
<el-input v-else ref="editControl" v-model="editValue" :placeholder="editPlaceholder" aria-describedby="param-edit-help" @input="editError = ''">
|
||||
<template v-if="editTarget.item.unit" #append>{{ editTarget.item.unit }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!--
|
||||
取值提示只对数字型参数有意义:枚举与开关是选出来的,摆「取值范围」只会添乱。
|
||||
分数式(4/360)保留原文是因为截成小数会算出不一样的绩效,这条得讲给业务听。
|
||||
-->
|
||||
<div v-if="editTarget.item.value_type !== 'ENUM' && editTarget.item.value_type !== 'BOOL'" class="edit-tip">
|
||||
取值范围:{{ editTarget.item.value_min ?? '不限' }} ~ {{ editTarget.item.value_max ?? '不限' }}
|
||||
<template v-if="editTarget.item.unit">(单位:{{ editTarget.item.unit }})</template>
|
||||
;比例可写成 4/360 这样的分数,系统按精确值计算,不四舍五入。
|
||||
<div v-if="editTarget.item.value_type !== 'ENUM' && editTarget.item.value_type !== 'BOOL'" id="param-edit-help" class="edit-tip">
|
||||
<p>取值范围:{{ editTarget.item.value_min ?? '不限' }} 至 {{ editTarget.item.value_max ?? '不限' }}<template v-if="editTarget.item.unit">,单位:{{ editTarget.item.unit }}</template>。</p>
|
||||
<p v-if="editTarget.item.value_type === 'INT'">请输入整数。</p>
|
||||
<p v-else>支持小数或分数。分数按原值保留,例如 4/360,无需先换算为小数。</p>
|
||||
</div>
|
||||
<div v-else class="edit-tip">改动提交后按你选择的生效日期起作用,旧值会保留在变更记录里。</div>
|
||||
<p class="form-hint">暂存后不会立即生效,仍需在页面的“待提交变更”中确认并提交。</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmEdit">加入待提交</el-button>
|
||||
<el-button type="primary" @click="confirmEdit">暂存修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@ -679,13 +724,13 @@ onMounted(() => {
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-button :loading="comparing" @click="doCompare">对比</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
|
||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
||||
</div>
|
||||
<template v-if="diffSearched">
|
||||
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
|
||||
<el-table :data="diffResult" size="small" max-height="260">
|
||||
<el-table-column prop="param_name" label="参数" min-width="130" />
|
||||
<el-table-column label="维度" width="90">
|
||||
<el-table-column label="适用范围" width="90">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="90">
|
||||
@ -739,7 +784,7 @@ onMounted(() => {
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleWithdraw(row)"
|
||||
>
|
||||
:disabled="!permissionUser.can('system:params:withdraw')" :title="permissionUser.permissionReason('system:params:withdraw')">
|
||||
撤回
|
||||
</el-button>
|
||||
</template>
|
||||
@ -759,7 +804,7 @@ onMounted(() => {
|
||||
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
|
||||
<el-table v-loading="historyLoading" :data="historyRows" size="small">
|
||||
<el-table-column prop="effective_date" label="生效日期" width="105" />
|
||||
<el-table-column label="维度" width="90">
|
||||
<el-table-column label="适用范围" width="90">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值 → 新值" min-width="130">
|
||||
@ -785,6 +830,7 @@ onMounted(() => {
|
||||
|
||||
<!-- 计算预览抽屉 -->
|
||||
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
|
||||
<p class="preview-scope-note">按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。</p>
|
||||
<el-form label-width="90px" @submit.prevent>
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="previewForm.customer_type" style="width: 100%">
|
||||
@ -838,7 +884,7 @@ onMounted(() => {
|
||||
<el-switch v-model="previewForm.probation" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="previewLoading" @click="runPreview">试 算</el-button>
|
||||
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@ -868,6 +914,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -883,6 +930,22 @@ onMounted(() => {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.param-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.param-definition {
|
||||
width: 36%;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.approval-notice {
|
||||
margin: 0 8px 16px;
|
||||
color: var(--ys-ink-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.param-table th {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
@ -906,12 +969,15 @@ onMounted(() => {
|
||||
|
||||
.param-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
margin-top: 2px;
|
||||
color: var(--ys-ink-2);
|
||||
margin-top: 6px;
|
||||
line-height: 1.7;
|
||||
max-width: 54ch;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 1.9;
|
||||
@ -959,6 +1025,7 @@ onMounted(() => {
|
||||
*/
|
||||
.submit-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
@ -969,17 +1036,38 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.edit-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
margin-top: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.param-edit-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.preview-scope-note {
|
||||
margin: 0 0 20px;
|
||||
color: var(--ys-ink-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.edit-tip {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
color: var(--ys-ink-2);
|
||||
background: var(--ys-mist);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.edit-tip p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-tip p + p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.drawer-block {
|
||||
@ -988,6 +1076,7 @@ onMounted(() => {
|
||||
|
||||
.drawer-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@ -6,13 +6,15 @@ import * as roleApi from '@/api/roles'
|
||||
import type { RoleItem, RoleQueryParams } from '@/api/roles'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import PermissionDrawer from '@/components/authorization/PermissionDrawer.vue'
|
||||
import CreateRoleDrawer from '@/components/authorization/CreateRoleDrawer.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { ROLE_ADMIN, STATUS_OPTIONS } from '@/utils/constants'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 角色管理(轻量版:不含权限点分配,权限清单待业务方给出后启用)。
|
||||
* 角色资料与功能权限管理。
|
||||
*
|
||||
* 与后端的保护规则对齐(后端是唯一防线,此处禁用按钮只是体验):
|
||||
* - 内置 admin 角色不可删除、不可禁用
|
||||
@ -20,9 +22,13 @@ import { handledError } from '@/utils/error'
|
||||
* 直接删会静默剥离这些用户的权限)
|
||||
* - 编码创建后不可修改(授权判定的锚点)
|
||||
* - 禁用角色不再出现在用户表单下拉中,且**不可再分配给用户**;
|
||||
* 已绑定用户的既有授权不受影响
|
||||
* 停用后立即停止向已绑定用户提供授权
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
const permissionRole = ref<RoleItem | null>(null)
|
||||
const permissionOpen = ref(false)
|
||||
const createOpen = ref(false)
|
||||
function openPermissions(row: RoleItem): void { permissionRole.value = row; permissionOpen.value = true }
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
@ -86,10 +92,7 @@ const rules = computed<FormRules>(() => ({
|
||||
}))
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, { role_code: '', role_name: '', description: '', sort_order: 0 })
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: RoleItem): void {
|
||||
@ -105,29 +108,19 @@ function openEdit(row: RoleItem): void {
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (editingId.value === null || submitting.value) return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
await roleApi.updateRole(editingId.value, {
|
||||
role_name: form.role_name,
|
||||
description: form.description,
|
||||
// el-input-number 清空后为 undefined,被 JSON 省略时后端会取默认 0,
|
||||
// 排序被静默重置 —— 提交前兜底为 0 并明确表达「未填即 0」的意图
|
||||
sort_order: form.sort_order ?? 0,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await roleApi.createRole({
|
||||
role_code: form.role_code,
|
||||
role_name: form.role_name,
|
||||
description: form.description || undefined,
|
||||
sort_order: form.sort_order ?? 0,
|
||||
})
|
||||
ElMessage.success('角色已创建')
|
||||
}
|
||||
await roleApi.updateRole(editingId.value, {
|
||||
role_name: form.role_name,
|
||||
description: form.description,
|
||||
// el-input-number 清空后为 undefined,明确按 0 提交,避免静默省略字段。
|
||||
sort_order: form.sort_order ?? 0,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
@ -145,7 +138,7 @@ async function handleToggleStatus(row: RoleItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText:
|
||||
next === 0
|
||||
? `确定禁用角色「${row.role_name}」?禁用后该角色不再出现在用户表单的下拉中且不可再分配,但已绑定用户的权限不受影响。`
|
||||
? `确定禁用角色「${row.role_name}」?将立即撤销该角色给 ${row.user_count} 个用户提供的权限,其他启用角色的权限继续生效。`
|
||||
: `确定启用角色「${row.role_name}」?`,
|
||||
title: `${action}确认`,
|
||||
confirmButtonText: action,
|
||||
@ -175,7 +168,7 @@ onMounted(load)
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">角色管理</h1>
|
||||
<p class="page-desc">
|
||||
决定账号能进哪些功能;内置 admin 不可删停,仍有用户绑定的角色需先解绑才能删除。
|
||||
新建时填写角色资料并选择功能权限,再到用户管理分配角色。权限变更会同步作用于绑定用户。
|
||||
</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
@ -253,9 +246,11 @@ onMounted(load)
|
||||
<el-table-column label="用户数" width="90" align="right">
|
||||
<template #default="{ row }"><span class="cell-num">{{ row.user_count }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<el-table-column label="权限项" width="90" align="right"><template #default="{row}">{{ row.permission_count }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openPermissions(row)">{{ isBuiltin(row) ? '查看权限' : '配置权限' }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
|
||||
<el-button
|
||||
@ -296,6 +291,8 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PermissionDrawer v-model="permissionOpen" :id="permissionRole?.id ?? null" :name="permissionRole?.role_name" kind="role" @saved="load" />
|
||||
<CreateRoleDrawer v-model="createOpen" @saved="load" />
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import type { UploadRawFile } from 'element-plus'
|
||||
@ -112,6 +115,7 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
|
||||
<div class="card-body">
|
||||
<el-form
|
||||
:disabled="!permissionUser.can('system:settings:update')"
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@ -137,6 +141,7 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
|
||||
<el-form-item label="logo">
|
||||
<el-upload
|
||||
:disabled="!permissionUser.can('system:settings:update')"
|
||||
class="logo-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
@ -163,7 +168,7 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
不会触发浏览器原生提交。
|
||||
-->
|
||||
<div class="card-foot">
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:settings:update')" :title="permissionUser.permissionReason('system:settings:update')">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ const props = defineProps<{ report: AuditReport }>()
|
||||
const linkLabels: Record<string, string> = {
|
||||
row_count: '销货类收款明细', delivery_matches: '销货单匹配', detail_matches: '销货明细匹配',
|
||||
direct_order_matches: '收款明细直接关联订单', detail_order_matches: '经销货明细关联订单',
|
||||
unambiguous_order_matches: '订单关联无冲突', invalid_order_ids: '悬空直接订单 ID', conflicting_order_ids: '两路径订单冲突',
|
||||
unambiguous_order_matches: '订单关联一致', invalid_order_ids: '关联订单不存在', conflicting_order_ids: '关联订单不一致',
|
||||
}
|
||||
const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map(([key, value]) => ({
|
||||
label: linkLabels[key] ?? key, value,
|
||||
@ -16,19 +16,16 @@ const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map((
|
||||
|
||||
<template>
|
||||
<section class="audit-result" aria-label="关联核验结果">
|
||||
<p class="audit-caption">核验于 {{ formatDateTime(report.captured_at) }} · 源引擎 {{ report.source_version }}</p>
|
||||
<el-alert type="warning" :closable="false" show-icon title="SQL Server 2008 R2 同版本验收待完成;关联存在不代表业务口径已确认。" />
|
||||
<p v-if="report.relationship_status !== 'OBSERVED'" role="alert">单据类型未能唯一确认,关联统计未执行。</p>
|
||||
<p class="audit-caption">检查时间:{{ formatDateTime(report.captured_at) }}</p>
|
||||
<p v-if="report.relationship_status !== 'OBSERVED'" role="alert">无法确认单据类型,本次未完成关联检查。请核对数据后重新检查。</p>
|
||||
<el-table v-if="rows.length" :data="rows" size="small" class="audit-table">
|
||||
<el-table-column prop="label" label="核验项目" min-width="190" />
|
||||
<el-table-column label="明细行数" width="120" align="right"><template #default="{ row }"><span class="cell-num">{{ row.value.toLocaleString() }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="report.order_coverage" class="coverage-note">按关联销货单统计:无订单 {{ report.order_coverage.without_order?.toLocaleString() }} 行,单订单 {{ report.order_coverage.single_order?.toLocaleString() }} 行,多订单 {{ report.order_coverage.multiple_orders?.toLocaleString() }} 行。统计单位为收款明细行,同一销货单可能出现多次。</p>
|
||||
<h3>收付款与系统状态</h3>
|
||||
<h3>收付款统计</h3>
|
||||
<p>{{ report.receipt_kinds?.map((item) => `${item.voucher_type_name} ${item.row_count.toLocaleString()} 条`).join(';') || '暂无统计' }}</p>
|
||||
<p>{{ report.state_enums?.map((item) => `${item.id}:${item.name}`).join(';') }}</p>
|
||||
<h3>待确认口径</h3>
|
||||
<dl><template v-for="item in report.pending_confirmations" :key="item.key"><dt>{{ item.title }}</dt><dd>{{ item.reason }}</dd></template></dl>
|
||||
<p v-if="report.state_enums?.length">单据状态:{{ report.state_enums.map((item) => item.name).join('、') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import * as api from '@/api/tplus'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
@ -9,54 +9,87 @@ import AuditResult from './AuditResult.vue'
|
||||
import { entities, scopeLabel, statuses } from './labels'
|
||||
|
||||
const number = defineModel<string | null>({ required: true })
|
||||
const props = defineProps<{ currentBatchNumber?: string | null }>()
|
||||
const detail = ref<api.TPlusDetail | null>(null)
|
||||
const active = computed(() => !!detail.value && ['QUEUED', 'RUNNING'].includes(detail.value.status))
|
||||
const differentTask = computed(() => !!props.currentBatchNumber && props.currentBatchNumber !== detail.value?.batch_no)
|
||||
const entityRows = computed(() => detail.value?.progress?.entities ?? [])
|
||||
const entityStatuses: Record<string, string> = { WAITING: '等待执行', COUNTING: '统计中', READING: '读取中', COMPLETED: '已读取', FAILED: '异常' }
|
||||
const triggerLabels: Record<string, string> = { MANUAL: '手动创建', SCHEDULED: '每周自动采集', AUTO_RETRY: '自动重试' }
|
||||
const numberText = (value: number | null | undefined): string => value == null ? '未记录' : value.toLocaleString()
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const errorEntityType = ref<api.EntityType>()
|
||||
let sequence = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let controller: AbortController | undefined
|
||||
const { records, total, pageNum, pageSize, loading: errorsLoading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<api.TPlusError, api.ErrorQuery>(
|
||||
(query) => api.listErrors(number.value!, query),
|
||||
() => ({ entity_type: errorEntityType.value }),
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load(background = false): Promise<void> {
|
||||
const current = ++sequence
|
||||
const key = number.value
|
||||
if (!key) return
|
||||
detail.value = null
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
if (!background) loading.value = true
|
||||
try {
|
||||
const result = await api.getBatch(key)
|
||||
const previousErrors = detail.value?.failed_record_count
|
||||
const result = await api.getBatch(key, {silentError: background, signal: controller.signal})
|
||||
if (current !== sequence) return
|
||||
detail.value = result
|
||||
if (result.failed_record_count) await search()
|
||||
failed.value = false
|
||||
if (result.failed_record_count && (!background || result.failed_record_count !== previousErrors)) await search()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (!background && current === sequence) handledError(error)
|
||||
if (current === sequence) failed.value = true
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
} finally {
|
||||
if (current === sequence) {
|
||||
loading.value = false
|
||||
clearTimeout(timer)
|
||||
if (number.value && detail.value && ['QUEUED', 'RUNNING'].includes(detail.value.status)) {
|
||||
timer = setTimeout(() => { if (!document.hidden) void load(true); else scheduleHidden() }, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
function scheduleHidden(): void { timer = setTimeout(() => { if (!document.hidden) void load(true); else scheduleHidden() }, 5000) }
|
||||
watch(number, (value) => { ++sequence; clearTimeout(timer); controller?.abort(); detail.value = null; failed.value = false; errorEntityType.value = undefined; if (value) void load() }, { immediate: true })
|
||||
onUnmounted(() => { ++sequence; clearTimeout(timer); controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="!!number" title="T+ 任务详情" width="min(880px, 96vw)" @update:model-value="number = null">
|
||||
<div v-loading="loading" class="detail-body">
|
||||
<el-alert v-if="failed" type="error" title="详情加载失败,请重试" :closable="false"><el-button link @click="load">重新加载</el-button></el-alert>
|
||||
<el-alert v-if="failed" type="error" title="详情更新失败,请重试" :closable="false"><el-button link @click="load()">重新加载</el-button></el-alert>
|
||||
<template v-if="detail">
|
||||
<p class="cell-code batch-number">{{ detail.batch_no }}</p>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="状态">{{ statuses[detail.status] }}</el-descriptions-item>
|
||||
<el-descriptions-item label="范围">{{ scopeLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.started_at" label="执行时间">{{ formatDateTime(detail.started_at) }} → {{ formatDateTime(detail.finished_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.parent_batch_no" label="原批次">{{ detail.parent_batch_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数量">源记录 {{ detail.source_record_count.toLocaleString() }},新增 {{ detail.staged_record_count.toLocaleString() }},重复跳过 {{ detail.skipped_record_count.toLocaleString() }},异常 {{ detail.failed_record_count.toLocaleString() }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="detail.status === 'QUEUED'" role="status">任务等待读取服务处理,可稍后刷新查看。</p>
|
||||
<p v-if="detail.error_summary" role="alert">{{ detail.error_summary }}</p>
|
||||
<AuditResult v-if="detail.audit_result" :report="detail.audit_result" />
|
||||
<div class="detail-summary"><span class="task-status" :data-status="detail.status">{{ statuses[detail.status] }}</span><span>{{ detail.task_type === 'AUDIT' ? '关联检查' : '数据读取' }} · {{ scopeLabel(detail) }}</span></div>
|
||||
<p v-if="differentTask" class="detail-note">当前查看的是所选任务;主页面正在展示另一项任务。</p>
|
||||
<dl class="task-meta">
|
||||
<div class="meta-wide"><dt>任务编号</dt><dd class="cell-code batch-number">{{ detail.batch_no }}</dd></div>
|
||||
<div><dt>任务来源</dt><dd>{{ triggerLabels[detail.trigger_type ?? ''] ?? '历史任务,来源未记录' }}</dd></div>
|
||||
<div><dt>创建时间</dt><dd>{{ formatDateTime(detail.create_time) }}</dd></div>
|
||||
<div><dt>开始时间</dt><dd>{{ formatDateTime(detail.started_at, detail.status === 'QUEUED' ? '尚未开始' : '未记录') }}</dd></div>
|
||||
<div><dt>结束时间</dt><dd>{{ formatDateTime(detail.finished_at, active ? '尚未结束' : '未记录') }}</dd></div>
|
||||
<div v-if="detail.parent_batch_no" class="meta-wide"><dt>重试自</dt><dd><el-button link type="primary" @click="number = detail.parent_batch_no">{{ detail.parent_batch_no }}</el-button></dd></div>
|
||||
</dl>
|
||||
<section v-if="detail.task_type === 'READ'" class="read-results" aria-label="读取明细">
|
||||
<h3>读取明细</h3>
|
||||
<div v-if="detail.status !== 'QUEUED'" class="result-totals"><span>源记录 <b>{{ numberText(detail.source_record_count) }}</b></span><span>新增 <b>{{ numberText(detail.staged_record_count) }}</b></span><span>重复跳过 <b>{{ numberText(detail.skipped_record_count) }}</b></span><span>异常 <b>{{ numberText(detail.failed_record_count) }}</b></span></div>
|
||||
<el-table v-if="entityRows.length" :data="entityRows" size="small">
|
||||
<el-table-column label="数据范围" min-width="120"><template #default="{row}">{{ entities.find(item => item.value === row.entity_type)?.label ?? row.entity_type }}</template></el-table-column>
|
||||
<el-table-column label="已处理" min-width="90" align="right"><template #default="{row}">{{ numberText(row.processed_records) }}</template></el-table-column>
|
||||
<el-table-column label="总量" min-width="90" align="right"><template #default="{row}">{{ numberText(row.total_records) }}</template></el-table-column>
|
||||
<el-table-column label="处理状态" min-width="100"><template #default="{row}">{{ entityStatuses[row.status] ?? '未知' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<p v-else class="detail-note">{{ detail.status === 'QUEUED' ? '任务尚未开始,执行后会在这里显示各范围的读取结果。' : '该任务未记录分项读取进度,汇总结果见上方。' }}</p>
|
||||
</section>
|
||||
<p v-if="active" class="detail-note" role="status">任务详情每 5 秒更新,关闭后任务继续执行。</p>
|
||||
<el-alert v-if="detail.error_summary" class="task-error" type="warning" :title="detail.error_summary" :closable="false" show-icon />
|
||||
<AuditResult v-if="detail.task_type === 'AUDIT' && detail.audit_result" :report="detail.audit_result" />
|
||||
<section v-if="detail.failed_record_count" class="errors-section" aria-label="异常明细">
|
||||
<h3>异常明细</h3>
|
||||
<el-select v-model="errorEntityType" clearable placeholder="全部范围" aria-label="异常范围" @change="search">
|
||||
@ -64,7 +97,7 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
</el-select>
|
||||
<el-table v-loading="errorsLoading" :data="records" empty-text="暂无异常记录">
|
||||
<el-table-column label="范围" width="100"><template #default="{ row }">{{ entities.find((item) => item.value === row.entity_type)?.label ?? '任务' }}</template></el-table-column>
|
||||
<el-table-column prop="source_id" label="源 ID" width="90" />
|
||||
<el-table-column prop="source_id" label="T+ 记录标识" width="90" />
|
||||
<el-table-column prop="error_message" label="异常说明" min-width="240" />
|
||||
<el-table-column prop="retry_count" label="已重试" width="80" />
|
||||
</el-table>
|
||||
@ -72,15 +105,32 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="number = null">关闭</el-button><el-button :loading="loading" @click="load">刷新详情</el-button></template>
|
||||
<template #footer><el-button @click="number = null">关闭</el-button><el-button :loading="loading" @click="load()">刷新详情</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-body { min-height: 120px; }
|
||||
.detail-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; font-size: 14px; line-height: 1.7; }
|
||||
.task-status { font-weight: 600; color: var(--ys-ink); }
|
||||
.task-status[data-status='RUNNING'] { color: var(--ys-indigo); }
|
||||
.task-status[data-status='SUCCEEDED'] { color: var(--el-color-success-dark-2); }
|
||||
.task-status[data-status='FAILED'], .task-status[data-status='PARTIAL'] { color: var(--el-color-danger-dark-2); }
|
||||
.task-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; padding: 18px 0; margin: 16px 0 0; border-top: 1px solid var(--ys-line); }
|
||||
.task-meta > div { min-width: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; font-size: 13px; line-height: 1.7; }
|
||||
.task-meta dt { color: var(--ys-ink-2); }
|
||||
.task-meta dd { margin: 0; min-width: 0; font-variant-numeric: tabular-nums; }
|
||||
.task-meta .meta-wide { grid-column: 1 / -1; }
|
||||
.batch-number { overflow-wrap: anywhere; }
|
||||
.task-meta .el-button { height: auto; white-space: normal; overflow-wrap: anywhere; text-align: left; }
|
||||
.read-results { margin-top: 12px; }
|
||||
.result-totals { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 24px; padding: 0 0 14px; font-size: 12px; color: var(--ys-ink-2); }
|
||||
.result-totals b { color: var(--ys-ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.detail-note { margin: 12px 0; font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); }
|
||||
.task-error { margin-top: 16px; }
|
||||
.errors-section { margin-top: 24px; }
|
||||
.errors-section .el-select { width: 160px; margin-bottom: 12px; }
|
||||
h3 { font-size: 14px; }
|
||||
.pager-scroll { overflow-x: auto; }
|
||||
@media (max-width: 600px) { .task-meta { grid-template-columns: minmax(0, 1fr); gap: 12px; } .result-totals { gap: 8px 16px; } }
|
||||
</style>
|
||||
|
||||
@ -24,7 +24,7 @@ async function submit(): Promise<void> {
|
||||
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<void> {
|
||||
<el-checkbox v-for="item in entities" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<p class="task-note">{{ taskType === 'READ' ? '全量读取所选范围,重复快照自动跳过。只保存原始数据,不写入工资业务表。' : '核对收款方向、单据类型及订单关联,只返回字典和统计数量。' }}</p>
|
||||
<p class="task-note">{{ taskType === 'READ' ? '读取所选范围内的数据,自动跳过未变化的记录。读取完成后可预览导入,不会自动生成工资。' : '检查收付款单据与订单、销货单的关联情况,供导入前核对;不会修改业务数据。' }}</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="pending" @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pending" :disabled="!configured || (taskType === 'READ' && !selected.length)" @click="submit">加入处理队列</el-button>
|
||||
<el-button type="primary" :loading="pending" :disabled="!configured || (taskType === 'READ' && !selected.length)" @click="submit">创建任务</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@ -66,12 +69,13 @@ async function save(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="open" title="核算补充资料" width="min(720px, 94vw)" :close-on-click-modal="false">
|
||||
<el-alert title="只登记已核对的事实;未明确的口径请留空,预演会列出待补原因。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="fact-form" @submit.prevent="save">
|
||||
<el-dialog v-model="open" title="核算补充资料" width="min(720px, 94vw)">
|
||||
<el-alert title="只登记已核对的事实;资料不齐全时请先保留空白,核对结果会列出待补项目。" type="info" :closable="false" />
|
||||
<el-alert v-if="!permissionUser.can('tplus:facts')" title="当前为只读资料,可查看已保存内容;修改需管理员授权。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="fact-form" :disabled="!permissionUser.can('tplus:facts')" @submit.prevent="save">
|
||||
<el-form-item label="资料类型"><el-select v-model="kind" :disabled="!!fact" aria-label="资料类型"><el-option v-for="(label, value) in factKinds" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item v-if="sourceEntity[kind] && !fact" label="从已读取的源数据选择"><el-select filterable remote :remote-method="search" placeholder="按T+编号搜索;最多显示200条" aria-label="选择源数据" @change="chosen"><el-option v-for="item in options" :key="item.source_id" :value="item.source_id" :label="`${item.source_no ?? item.source_id} ${item.name} ${item.date}`" /></el-select></el-form-item>
|
||||
<el-form-item :label="kind === 'PERSONNEL' ? '人员工号(保留前导零)' : ['PRODUCT', 'CUSTOMER', 'RECEIPT', 'SHIPMENT', 'CORRECTION'].includes(kind) ? '对应源内部ID' : '资料标识(英文、数字或连字符)'"><el-input v-model="key" :disabled="!!fact" aria-label="资料标识" /></el-form-item>
|
||||
<el-form-item :label="kind === 'PERSONNEL' ? '人员工号(保留前导零)' : ['PRODUCT', 'CUSTOMER', 'RECEIPT', 'SHIPMENT', 'CORRECTION'].includes(kind) ? '对应 T+ 记录标识' : '资料标识(英文、数字或连字符)'"><el-input v-model="key" :disabled="!!fact" aria-label="资料标识" /></el-form-item>
|
||||
<template v-if="kind === 'PERSONNEL'">
|
||||
<el-form-item label="姓名"><el-input v-model="d.name" aria-label="姓名" /></el-form-item>
|
||||
<el-form-item label="岗位"><el-select v-model="d.role_code" aria-label="岗位"><el-option label="业务员" value="salesperson" /><el-option label="经理" value="sales_manager" /></el-select></el-form-item>
|
||||
@ -86,21 +90,21 @@ async function save(): Promise<void> {
|
||||
<el-form-item label="客户公司负责人姓名"><el-input v-model="d.owner_name" aria-label="客户公司负责人" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="kind === 'GROUP'">
|
||||
<el-form-item label="T+客户内部ID"><el-input v-model="d.customer_id" aria-label="T+客户内部ID" /></el-form-item>
|
||||
<el-form-item label="纳入平账的发货单内部ID(逗号分隔)"><el-input v-model="shipments" type="textarea" aria-label="平账发货单" /></el-form-item>
|
||||
<el-form-item label="纳入平账的收款单内部ID(逗号分隔)"><el-input v-model="receipts" type="textarea" aria-label="平账收款单" /></el-form-item>
|
||||
<el-form-item label="T+ 客户记录标识"><el-input v-model="d.customer_id" aria-label="T+ 客户记录标识" /></el-form-item>
|
||||
<el-form-item label="纳入平账的发货单记录标识(逗号分隔)"><el-input v-model="shipments" type="textarea" aria-label="平账发货单" /></el-form-item>
|
||||
<el-form-item label="纳入平账的收款单记录标识(逗号分隔)"><el-input v-model="receipts" type="textarea" aria-label="平账收款单" /></el-form-item>
|
||||
<el-form-item label="截至2026年2月28日的未收金额(无余额填0)"><el-input v-model="d.opening_balance" aria-label="期初应收余额" /></el-form-item>
|
||||
<el-form-item label="其中已由旧软件核算或已发放的收款ID"><el-input v-model="excluded" type="textarea" aria-label="排除历史已核算款" /></el-form-item>
|
||||
<el-form-item label="其中已核算或已发放的收款记录标识"><el-input v-model="excluded" type="textarea" aria-label="排除历史已核算款" /></el-form-item>
|
||||
<p class="page-desc">按同一 T+ 发货客户名头统一平账,不按合同拆组;成本固定取发货月份,计提参数按到账日取值。</p>
|
||||
<div class="checks"><el-checkbox v-model="d.scope_confirmed">本客户平账资料已核实</el-checkbox><el-checkbox v-model="d.opening_confirmed">期初应收已核实</el-checkbox><el-checkbox v-model="d.historical_settlement_confirmed">历史已核算及已发放款已核实</el-checkbox></div>
|
||||
</template>
|
||||
<template v-if="kind === 'GUIDANCE'">
|
||||
<el-form-item label="T+产品内部ID"><el-input v-model="d.product_id" aria-label="指导价产品" /></el-form-item>
|
||||
<el-form-item label="T+ 产品记录标识"><el-input v-model="d.product_id" aria-label="指导价产品" /></el-form-item>
|
||||
<el-form-item label="指导价月份"><el-date-picker v-model="d.month" type="month" value-format="YYYY-MM" aria-label="指导价月份" /></el-form-item>
|
||||
<el-form-item label="成本类型"><el-select v-model="d.basis" aria-label="成本类型"><el-option label="单位指导成本" value="UNIT" /><el-option label="指定发货明细的总成本" value="LINE_TOTAL" /></el-select></el-form-item>
|
||||
<el-form-item label="含税指导成本(元)"><el-input v-model="d.amount" aria-label="含税指导成本" /></el-form-item>
|
||||
<el-form-item label="计量单位"><el-input v-model="d.unit" aria-label="指导价单位" /></el-form-item>
|
||||
<el-form-item v-if="d.basis === 'LINE_TOTAL'" label="发货明细内部ID"><el-input v-model="d.shipment_line_id" aria-label="指导价发货明细" /></el-form-item>
|
||||
<el-form-item v-if="d.basis === 'LINE_TOTAL'" label="发货明细记录标识"><el-input v-model="d.shipment_line_id" aria-label="指导价发货明细" /></el-form-item>
|
||||
<el-checkbox v-model="d.is_finalized" :disabled="Boolean(props.fact?.data.is_finalized)">该发货月份指导成本已录入完毕并封存(封存后不能修改)</el-checkbox>
|
||||
</template>
|
||||
<template v-if="kind === 'RECEIPT'">
|
||||
@ -112,7 +116,7 @@ async function save(): Promise<void> {
|
||||
<el-form-item label="该次发货归属人员工号"><el-input v-model="d.personnel_id" aria-label="发货归属工号" /></el-form-item>
|
||||
<el-checkbox v-model="d.sales_amount_excludes_freight">已核实该明细含税销售额已剔除运费</el-checkbox>
|
||||
<el-form-item v-if="!d.sales_amount_excludes_freight" label="该明细应扣除的运费(元;已核实无运费填0,未核实留空)"><el-input v-model="d.freight_amount" aria-label="发货明细运费" /></el-form-item>
|
||||
<p class="page-desc">发货日期取原销货单日期。日期更正办法尚未确认,本轮暂不开放修改。</p>
|
||||
<p class="page-desc">发货日期使用原销货单的单据日期,不在此处修改。</p>
|
||||
</template>
|
||||
<template v-if="kind === 'CORRECTION'">
|
||||
<el-form-item label="更正对象"><el-select v-model="d.entity" aria-label="更正对象"><el-option label="发货更正单" value="SHIPMENT" /><el-option label="跨客户回款" value="RECEIPT" /></el-select></el-form-item>
|
||||
@ -133,7 +137,7 @@ async function save(): Promise<void> {
|
||||
<el-form-item label="确认依据与修改原因(必填)"><el-input v-model="evidence" type="textarea" :rows="3" maxlength="500" aria-label="确认依据与修改原因" placeholder="记录确认人、日期、例单或更正原因;不要填写账号密码" /></el-form-item>
|
||||
<p v-if="fact" class="page-desc">正在编辑第 {{ version }} 版,保存后新增版本,旧内容保留。</p>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="open = false">取消</el-button><el-button type="primary" :loading="saving" @click="save" :disabled="!payrollUser.canWritePayroll">保存资料</el-button></template>
|
||||
<template #footer><el-button @click="open = false">取消</el-button><el-button type="primary" :loading="saving" @click="save" :disabled="!permissionUser.can('tplus:facts')" :title="permissionUser.permissionReason('tplus:facts')">保存资料</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
|
||||
88
frontend/src/views/system/tplus/SyncProgress.vue
Normal file
88
frontend/src/views/system/tplus/SyncProgress.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TPlusBatch } from '@/api/tplus'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { entities, statuses } from './labels'
|
||||
|
||||
const props = withDefaults(defineProps<{ batch: TPlusBatch | null; compact?: boolean }>(), {compact: false})
|
||||
const emit = defineEmits<{ detail: [number: string] }>()
|
||||
const running = computed(() => props.batch?.status === 'RUNNING')
|
||||
const progress = computed(() => props.batch?.progress)
|
||||
const percentage = computed(() => progress.value?.percentage ?? null)
|
||||
const title = computed(() => {
|
||||
const batch = props.batch
|
||||
if (!batch) return '尚未开始同步'
|
||||
if (batch.task_type === 'AUDIT') return batch.status === 'SUCCEEDED' ? '关联检查已完成' : running.value ? '正在检查单据关联' : batch.status === 'QUEUED' ? '关联检查等待执行' : '关联检查需要处理异常'
|
||||
if (batch.status === 'SUCCEEDED') return '本次数据已采集完成'
|
||||
if (batch.status === 'FAILED') return '本次同步未完成'
|
||||
if (batch.status === 'PARTIAL') return '本次同步有异常记录'
|
||||
if (batch.status === 'QUEUED') return '任务已排队,等待读取'
|
||||
return progress.value?.phase === 'COUNTING' ? '正在统计读取总量' : '正在读取 T+ 数据'
|
||||
})
|
||||
const category = computed(() => entities.find(item => item.value === progress.value?.active_entity)?.label)
|
||||
const elapsed = computed(() => {
|
||||
const seconds = progress.value?.elapsed_seconds ?? 0
|
||||
if (seconds < 60) return `${seconds} 秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return minutes < 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
|
||||
})
|
||||
const numberText = (value: number | null | undefined): string => value == null ? '—' : value.toLocaleString()
|
||||
const entityState: Record<string, string> = {WAITING: '等待', READING: '读取中', COMPLETED: '已读取', FAILED: '异常'}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sync-progress" :class="{'is-compact': compact}" aria-label="同步进度">
|
||||
<div class="progress-heading"><div><p v-if="!compact" class="section-caption">{{ running ? '当前同步' : '最近一次同步' }}</p><h2>{{ title }}</h2></div><span v-if="batch" class="task-status" :data-status="batch.status">{{ statuses[batch.status] }}</span></div>
|
||||
<template v-if="batch">
|
||||
<div class="progress-reading"><strong>{{ percentage == null ? numberText(batch.source_record_count) : `${percentage.toFixed(1).replace(/\.0$/, '')}%` }}</strong><span>{{ percentage == null ? '条已处理' : `${numberText(batch.source_record_count)} / ${numberText(progress?.total_records)} 条` }}</span></div>
|
||||
<div class="meter-wrap" :class="{'is-indeterminate': percentage == null && ['RUNNING','QUEUED'].includes(batch.status)}"><progress class="sync-meter" :class="{'is-error': ['FAILED', 'PARTIAL'].includes(batch.status)}" :value="percentage ?? undefined" max="100" :aria-label="`同步进度:${percentage == null ? `已处理${batch.source_record_count}条,总量尚未取得` : `${percentage}%`}`" /></div>
|
||||
<div class="progress-context"><span>{{ running && category ? `当前读取:${category}` : running && percentage == null ? '任务正在执行,总量暂不可用' : batch.status === 'QUEUED' ? '读取服务会依次处理队列' : batch.status === 'SUCCEEDED' ? batch.task_type === 'AUDIT' ? '可在详情中查看检查结果' : '可前往资料与导入继续核对' : '查看任务详情了解处理结果' }}</span><span>已用时 {{ elapsed }}</span></div>
|
||||
<div v-if="!compact" class="result-counts"><span>新增 <b>{{ numberText(batch.staged_record_count) }}</b></span><span>重复跳过 <b>{{ numberText(batch.skipped_record_count) }}</b></span><span :class="{'has-errors': batch.failed_record_count > 0}">异常 <b>{{ numberText(batch.failed_record_count) }}</b></span></div>
|
||||
<ul v-if="!compact && progress?.entities.length" class="scope-progress" aria-label="分类读取进度"><li v-for="item in progress.entities" :key="item.entity_type" :data-state="item.status"><span>{{ entities.find(entity => entity.value === item.entity_type)?.label ?? item.entity_type }}</span><small>{{ entityState[item.status] ?? '等待' }}</small></li></ul>
|
||||
<div v-if="!compact" class="progress-footer"><span class="batch-ref" :title="batch.batch_no">{{ batch.batch_no }}</span><button class="detail-link" type="button" @click="emit('detail', batch.batch_no)">查看详情</button></div>
|
||||
<p v-if="!compact" class="updated">{{ running ? '进度每 5 秒更新' : `完成时间:${formatDateTime(batch.finished_at)}` }}</p>
|
||||
</template>
|
||||
<p v-else class="empty-note">连接就绪后,点击“立即同步”读取 T+ 数据;也可以开启每周自动采集。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.section-caption { margin: 0 0 6px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.task-status { font-size: 13px; color: var(--ys-ink-2); white-space: nowrap; }
|
||||
.task-status[data-status='RUNNING'] { color: var(--ys-indigo); }
|
||||
.task-status[data-status='SUCCEEDED'] { color: var(--el-color-success-dark-2); }
|
||||
.task-status[data-status='FAILED'], .has-errors { color: var(--el-color-danger-dark-2); }
|
||||
.progress-reading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12px; margin: 22px 0 12px; font-variant-numeric: tabular-nums; }
|
||||
.progress-reading strong { font-size: 32px; font-weight: 500; line-height: 1.2; color: var(--ys-indigo); }
|
||||
.progress-reading span, .progress-context { font-size: 13px; color: var(--ys-ink-2); }
|
||||
.meter-wrap { position: relative; overflow: hidden; border-radius: 4px; }
|
||||
.sync-meter { display: block; appearance: none; border: 0; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; accent-color: var(--ys-indigo); background: var(--ys-mist); }
|
||||
.sync-meter::-webkit-progress-bar { background: var(--ys-mist); border-radius: 4px; }
|
||||
.sync-meter::-webkit-progress-value { background: var(--ys-indigo); border-radius: 4px; transition: width 200ms ease; }
|
||||
.sync-meter.is-error::-webkit-progress-value { background: var(--el-color-danger); }
|
||||
.meter-wrap.is-indeterminate::after { content: ''; position: absolute; inset: 0 auto 0 0; width: 26%; border-radius: 4px; background: var(--ys-indigo); animation: sync-reading 2s ease-in-out infinite alternate; }
|
||||
.progress-context { display: flex; justify-content: space-between; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.result-counts { display: flex; gap: 24px; flex-wrap: wrap; margin: 20px 0 16px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.result-counts b { margin-left: 6px; font-weight: 500; color: var(--ys-ink); font-variant-numeric: tabular-nums; }
|
||||
.result-counts .has-errors b { color: var(--el-color-danger-dark-2); }
|
||||
.scope-progress { display: flex; gap: 8px 18px; flex-wrap: wrap; list-style: none; padding: 0; margin: 0; }
|
||||
.scope-progress li { display: flex; gap: 6px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.scope-progress small { font-size: inherit; }
|
||||
.scope-progress li[data-state='READING'] { color: var(--ys-indigo); }
|
||||
.scope-progress li[data-state='COMPLETED'] { color: var(--el-color-success-dark-2); }
|
||||
.scope-progress li[data-state='FAILED'] { color: var(--el-color-danger-dark-2); }
|
||||
.progress-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--ys-line); }
|
||||
.batch-ref { color: var(--ys-ink-2); font: 13px var(--ys-font-brand); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-link { background: none; border: 0; color: var(--ys-indigo); cursor: pointer; font: inherit; font-size: 13px; padding: 6px 0; white-space: nowrap; }
|
||||
.detail-link:focus-visible { outline: 2px solid var(--ys-indigo); outline-offset: 3px; }
|
||||
.updated, .empty-note { color: var(--ys-ink-2); font-size: 13px; margin: 10px 0 0; }
|
||||
.empty-note { padding: 28px 0; line-height: 1.8; font-size: 14px; }
|
||||
.is-compact { margin-bottom: 20px; }
|
||||
.is-compact .progress-reading { margin-top: 14px; }
|
||||
.is-compact .progress-reading strong { font-size: 24px; }
|
||||
@keyframes sync-reading { to { transform: translateX(285%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .meter-wrap.is-indeterminate::after { animation: none; } .sync-meter::-webkit-progress-value { transition: none; } }
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@ -34,7 +37,7 @@ async function queue(kind: api.WorkKind, preview?: api.WorkRun): Promise<void> {
|
||||
if (kind === 'MASTER_PREVIEW' && !entities.value.length) { ElMessage.warning('请选择基础数据'); return }
|
||||
if (kind === 'CALC_PREVIEW' && (!month.value || !split(groupText.value).length)) { ElMessage.warning('请选择核算月份和平账组'); return }
|
||||
if ((kind === 'MASTER_APPLY' || kind === 'BUSINESS_APPLY')) {
|
||||
try { await ElMessageBox.confirm('将按预览导入资料或正式业务单据。冲突和待补行保留,工资台账不会写入。', '执行基础导入', {type: 'warning'}) } catch { return }
|
||||
try { await ElMessageBox.confirm('将导入预览中已核实的数据。有冲突或需补充的记录会保留,导入不会直接生成工资。', '确认导入数据', {confirmButtonText: '确认导入', cancelButtonText: '返回核对', customClass: 'ys-confirm-box'}) } catch { return }
|
||||
}
|
||||
const input = (kind === 'MASTER_APPLY' || kind === 'BUSINESS_APPLY') ? {kind, preview_no: preview?.run_no}
|
||||
: kind === 'MASTER_PREVIEW' ? {kind, entity_types: entities.value, source_ids: split(idsText.value)}
|
||||
@ -63,7 +66,7 @@ async function exportReport(): Promise<void> {
|
||||
async function replay(): Promise<void> {
|
||||
if (!current.value || creating.value) return
|
||||
creating.value = true
|
||||
try { await api.createWork({kind: 'CALC_PREVIEW', replay_no: current.value.run_no, request_key: crypto.randomUUID()}); ElMessage.success('已按原快照排队重跑'); await refresh() }
|
||||
try { await api.createWork({kind: 'CALC_PREVIEW', replay_no: current.value.run_no, request_key: crypto.randomUUID()}); ElMessage.success('已创建重新核对任务,使用原任务的资料和规则'); await refresh() }
|
||||
catch (e) { handledError(e) } finally { creating.value = false }
|
||||
}
|
||||
function value(data: Record<string, unknown>, key: string): string { return data[key] == null ? '—' : String(data[key]) }
|
||||
@ -77,13 +80,13 @@ onMounted(refresh)
|
||||
<template>
|
||||
<section class="card card-body workbench" aria-labelledby="workbench-title">
|
||||
<div class="section-head"><div><h2 id="workbench-title">基础导入与核算预演</h2><p class="page-desc">先核对资料和资金分配过程,预演不生成正式工资,不改变月结或已发放结果。</p></div><el-button :loading="loading" @click="refresh">刷新工作区</el-button></div>
|
||||
<el-alert v-if="failed" type="error" title="工作区加载失败,请确认数据库迁移已执行后刷新。" :closable="false" show-icon />
|
||||
<el-alert v-if="failed" type="error" title="数据加载失败,请刷新重试;若仍无法加载,请联系管理员。" :closable="false" show-icon />
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="基础导入" name="import">
|
||||
<el-form label-position="top" class="create-form" @submit.prevent="queue('MASTER_PREVIEW')">
|
||||
<el-form-item label="本次预览的数据"><el-checkbox-group v-model="entities" aria-label="导入数据类型"><el-checkbox value="personnel">人员</el-checkbox><el-checkbox value="products">产品</el-checkbox><el-checkbox value="customers">客户</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="指定源内部ID(选填,逗号分隔;留空处理所选类型全部数据)"><el-input v-model="idsText" aria-label="指定导入范围" /></el-form-item>
|
||||
<el-button type="primary" :loading="creating" @click="queue('MASTER_PREVIEW')" :disabled="!payrollUser.canWritePayroll">生成导入预览</el-button>
|
||||
<el-form-item label="指定记录标识(选填,多个标识用逗号分隔;留空处理所选类型全部数据)"><el-input v-model="idsText" aria-label="指定导入范围" /></el-form-item>
|
||||
<el-button type="primary" :loading="creating" @click="queue('MASTER_PREVIEW')" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">生成导入预览</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="核算预演" name="calc">
|
||||
@ -91,38 +94,38 @@ onMounted(refresh)
|
||||
<el-form label-position="top" class="create-form" @submit.prevent="queue('CALC_PREVIEW')">
|
||||
<el-form-item label="核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" aria-label="核对月份" /></el-form-item>
|
||||
<el-form-item label="已登记的平账组标识(逗号分隔)"><el-input v-model="groupText" aria-label="预演平账组" placeholder="请先在补充资料中登记平账范围" /></el-form-item>
|
||||
<el-button type="primary" :loading="creating" @click="queue('CALC_PREVIEW')" :disabled="!payrollUser.canWritePayroll">生成核算预演</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="queue('CALC_PREVIEW')" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">生成核算预演</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="正式业务导入" name="business-import">
|
||||
<el-alert title="预览核实后导入正式业务、发货和回款;生成工资需另行正式核算。" type="info" :closable="false" />
|
||||
<el-form class="create-form" label-position="top"><el-form-item label="资料核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" /></el-form-item><el-form-item label="已核实的独立业务平账组"><el-input v-model="groupText" placeholder="填写组标识,以逗号或换行分隔" /></el-form-item><el-button type="primary" :disabled="!payrollUser.canWritePayroll" :loading="creating" @click="queue('BUSINESS_PREVIEW')">生成正式导入预览</el-button></el-form>
|
||||
<el-form class="create-form" label-position="top"><el-form-item label="资料核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" /></el-form-item><el-form-item label="已核实的独立业务平账组"><el-input v-model="groupText" placeholder="填写平账组标识,以逗号或换行分隔" /></el-form-item><el-button type="primary" :disabled="!permissionUser.can('tplus:preview')" :loading="creating" @click="queue('BUSINESS_PREVIEW')" :title="permissionUser.permissionReason('tplus:preview')">生成正式导入预览</el-button></el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="补充资料" name="facts">
|
||||
<div class="toolbar"><el-select v-model="factKind" clearable placeholder="全部资料" aria-label="筛选资料类型" @change="factPage = 1; refresh()"><el-option v-for="(label, key) in factKinds" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" @click="edit(null)" :disabled="!payrollUser.canWritePayroll">新增补充资料</el-button></div>
|
||||
<el-table v-loading="loading" :data="facts" empty-text="暂无补充资料,请按客户确认结果填写。">
|
||||
<el-table-column label="资料类型" min-width="150"><template #default="{row}">{{ factKinds[row.kind as api.FactKind] }}</template></el-table-column><el-table-column prop="fact_key" label="标识" min-width="120" /><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据" min-width="230" /><el-table-column label="操作" min-width="130"><template #default="{row}"><el-button link type="primary" @click="edit(row)" :disabled="!payrollUser.canWritePayroll">查看 / 编辑</el-button><el-button link @click="showHistory(row)">历史</el-button></template></el-table-column>
|
||||
<div class="toolbar"><el-select v-model="factKind" clearable placeholder="全部资料" aria-label="筛选资料类型" @change="factPage = 1; refresh()"><el-option v-for="(label, key) in factKinds" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" @click="edit(null)" :disabled="!permissionUser.can('tplus:facts')" :title="permissionUser.permissionReason('tplus:facts')">新增补充资料</el-button></div>
|
||||
<el-table v-loading="loading" :data="facts" empty-text="暂无补充资料,请根据已核实的业务情况填写。">
|
||||
<el-table-column label="资料类型" min-width="150"><template #default="{row}">{{ factKinds[row.kind as api.FactKind] }}</template></el-table-column><el-table-column prop="fact_key" label="标识" min-width="120" /><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据" min-width="230" /><el-table-column label="操作" min-width="130"><template #default="{row}"><el-button link type="primary" @click="edit(row)">{{ permissionUser.can('tplus:facts') ? '查看 / 编辑' : '查看资料' }}</el-button><el-button link @click="showHistory(row)">历史</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="factsTotal" :page-num="factPage" :page-size="10" @page-change="factPage = $event; refresh()" @size-change="refresh" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<h3>工作区任务</h3>
|
||||
<h3>导入与核对任务</h3>
|
||||
<el-table v-loading="loading" :data="runs" empty-text="暂无任务,可先生成基础导入预览。">
|
||||
<el-table-column label="任务" min-width="200"><template #default="{row}"><el-button link type="primary" @click="show(row)">{{ workKinds[row.kind as api.WorkKind] }}</el-button><p class="page-desc">{{ row.run_no }}</p></template></el-table-column>
|
||||
<el-table-column label="状态" min-width="115"><template #default="{row}">{{ actions[row.status] ?? row.status }}</template></el-table-column>
|
||||
<el-table-column label="创建时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column>
|
||||
<el-table-column label="异常原因" min-width="180"><template #default="{row}">{{ reasonText(row.error_code) || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作" min-width="170"><template #default="{row}"><el-button link type="primary" @click="show(row)">查看明细</el-button><el-button v-if="(row.kind === 'MASTER_PREVIEW' || row.kind === 'BUSINESS_PREVIEW') && ['SUCCEEDED','PARTIAL'].includes(row.status)" link type="primary" :disabled="!payrollUser.canWritePayroll || (creating)" @click="queue(row.kind === 'BUSINESS_PREVIEW' ? 'BUSINESS_APPLY' : 'MASTER_APPLY', row)">执行导入</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" min-width="170"><template #default="{row}"><el-button link type="primary" @click="show(row)">查看明细</el-button><el-button v-if="(row.kind === 'MASTER_PREVIEW' || row.kind === 'BUSINESS_PREVIEW') && ['SUCCEEDED','PARTIAL'].includes(row.status)" link type="primary" :disabled="!permissionUser.can('tplus:import') || (creating)" @click="queue(row.kind === 'BUSINESS_PREVIEW' ? 'BUSINESS_APPLY' : 'MASTER_APPLY', row)" :title="permissionUser.permissionReason('tplus:import')">执行导入</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="total" :page-num="page" :page-size="10" @page-change="page = $event; refresh()" @size-change="refresh" />
|
||||
<FactEditor v-model="editing" :fact="editedFact" @saved="refresh" />
|
||||
<el-dialog v-model="details" title="处理结果与逐笔依据" width="min(1240px, 96vw)">
|
||||
<template v-if="current">
|
||||
<el-alert title="仅为导入或预演结果,未写入正式工资台账。" type="info" :closable="false" />
|
||||
<p class="fingerprint">输入指纹:{{ current.snapshot_hash ?? '尚未处理' }}</p>
|
||||
<el-alert title="本次用于数据导入或核对,不会直接生成月度工资。" type="info" :closable="false" />
|
||||
|
||||
<div class="counts"><span v-for="(count, key) in counts" :key="key">{{ actions[key] ?? key }}:{{ count }}</span></div>
|
||||
<el-table v-if="summaries.length" :data="summaries"><el-table-column prop="key" label="平账组" min-width="100" /><el-table-column prop="receipt_total" label="纳入回款" min-width="110" /><el-table-column prop="excluded_receipt_total" label="未纳入回款" min-width="115" /><el-table-column prop="allocated_total" label="分配合计" min-width="110" /><el-table-column prop="opening_remaining" label="剩余旧应收" min-width="110" /><el-table-column prop="prepayment_remaining" label="预收待发货" min-width="110" /><el-table-column prop="performance_total" label="累计可核对计提" min-width="130" /><el-table-column prop="month_performance_total" label="本月可核对计提" min-width="140" /><el-table-column label="待补说明" min-width="180"><template #default="{row}">{{ reasonText(row.reasons) || '逐笔见下表;经理工资尚未生成' }}</template></el-table-column></el-table>
|
||||
<div class="toolbar"><el-select v-model="action" clearable placeholder="全部处理结果" aria-label="筛选处理结果" @change="linePage = 1; loadLines()"><el-option v-for="(label,key) in actions" :key="key" :value="key" :label="label" /></el-select><el-button @click="show(current)">刷新结果</el-button><el-button v-if="current.kind === 'CALC_PREVIEW' && ['SUCCEEDED', 'PARTIAL'].includes(current.status)" :loading="creating" @click="replay" :disabled="!payrollUser.canWritePayroll">按原快照重跑</el-button><el-button :disabled="!current.snapshot_hash" @click="exportReport">导出核对报告</el-button></div>
|
||||
<div class="toolbar"><el-select v-model="action" clearable placeholder="全部处理结果" aria-label="筛选处理结果" @change="linePage = 1; loadLines()"><el-option v-for="(label,key) in actions" :key="key" :value="key" :label="label" /></el-select><el-button @click="show(current)">刷新结果</el-button><el-button v-if="current.kind === 'CALC_PREVIEW' && ['SUCCEEDED', 'PARTIAL'].includes(current.status)" :loading="creating" @click="replay" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">按按原依据重新核对</el-button><el-button :disabled="(!current.snapshot_hash) || !permissionUser.can('tplus:export')" @click="exportReport" :title="permissionUser.permissionReason('tplus:export')">导出核对报告</el-button></div>
|
||||
<el-table :data="lines" empty-text="任务尚未处理或当前筛选没有记录">
|
||||
<el-table-column prop="source_id" label="源记录 / 平账组" min-width="130" /><el-table-column label="处理结果" min-width="100"><template #default="{row}">{{ actions[row.action] ?? row.action }}</template></el-table-column>
|
||||
<el-table-column label="名称 / 回款单" min-width="150"><template #default="{row}">{{ rowName(row.data) }}</template></el-table-column>
|
||||
@ -133,14 +136,14 @@ onMounted(refresh)
|
||||
<AppPagination :total="linesTotal" :page-num="linePage" :page-size="20" @page-change="linePage = $event; loadLines()" @size-change="loadLines" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="historyOpen" title="补充资料修改历史" width="min(850px, 94vw)"><el-table :data="history"><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据 / 修改原因" min-width="260" /><el-table-column prop="requested_by" label="操作人ID" width="110" /><el-table-column label="时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column></el-table></el-dialog>
|
||||
<el-dialog v-model="historyOpen" title="补充资料修改历史" width="min(850px, 94vw)"><el-table :data="history"><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据 / 修改原因" min-width="260" /><el-table-column prop="requested_by" label="操作人员编号" width="110" /><el-table-column label="时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column></el-table></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench { margin-top: 24px; } .section-head, .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
h2 { margin: 0 0 8px; font-size: 18px; } h3 { margin: 24px 0 12px; font-size: 16px; } .create-form { max-width: 720px; padding: 16px 0; }
|
||||
.toolbar { justify-content: flex-start; margin-top: 16px; flex-wrap: wrap; } .toolbar .el-select { width: 220px; } .fingerprint { overflow-wrap: anywhere; color: var(--ys-ink-2); font-size: 12px; } .counts { display: flex; gap: 24px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.toolbar { justify-content: flex-start; margin-top: 16px; flex-wrap: wrap; } .toolbar .el-select { width: 220px; } .counts { display: flex; gap: 24px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.workbench :deep(.el-table .el-button + .el-button) { margin-left: 12px; }
|
||||
@media(max-width: 640px) { .section-head { align-items: flex-start; flex-direction: column; } .toolbar .el-select { width: 100%; } }
|
||||
</style>
|
||||
|
||||
@ -1,44 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import * as api from '@/api/tplus'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { newRequestKey } from '@/utils/uuid'
|
||||
import AuditResult from './AuditResult.vue'
|
||||
import WorkbenchPanel from './WorkbenchPanel.vue'
|
||||
import BatchDetailDialog from './BatchDetailDialog.vue'
|
||||
import CreateTaskDialog from './CreateTaskDialog.vue'
|
||||
import SyncProgress from './SyncProgress.vue'
|
||||
import WorkbenchPanel from './WorkbenchPanel.vue'
|
||||
import { scopeLabel, statuses } from './labels'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useSyncPage } from './useSyncPage'
|
||||
|
||||
const user = useUserStore()
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const phases: Record<string, string> = {UNKNOWN: '尚无心跳记录', IDLE: '等待任务', RUNNING: '处理中', STOPPED: '已停止'}
|
||||
|
||||
const overview = ref<api.TPlusOverview | null>(null)
|
||||
const overviewFailed = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const creating = ref(false)
|
||||
const detailNumber = ref<string | null>(null)
|
||||
const status = ref<api.TaskStatus>()
|
||||
const taskType = ref<api.TaskType>()
|
||||
const retrying = ref<string | null>(null)
|
||||
const route = useRoute()
|
||||
const {overview, records, total, pageNum, pageSize, status, taskType, loading, failed,
|
||||
saving, refreshedAt, refresh, changeAutomation, search, onPageChange, onSizeChange} = useSyncPage()
|
||||
const activeTab = ref('records'), creating = ref(false), retrying = ref<string | null>(null)
|
||||
const submittedNumber = ref<string | null>(null)
|
||||
const detailNumber = ref<string | null>(typeof route.query.batch === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(route.query.batch) ? route.query.batch : null)
|
||||
const retryKeys = new Map<string, string>()
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<api.TPlusBatch, api.BatchQuery>(api.listBatches, () => ({ status: status.value, task_type: taskType.value }))
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
overviewFailed.value = false
|
||||
try { overview.value = await api.getOverview() }
|
||||
catch (error) { overviewFailed.value = true; handledError(error) }
|
||||
finally { refreshing.value = false }
|
||||
await load()
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const automation = computed(() => overview.value?.automation)
|
||||
const phases: Record<string, string> = {UNKNOWN:'暂未收到心跳', IDLE:'等待读取任务', RUNNING:'正在处理任务', STOPPED:'已停止'}
|
||||
const connectionLabel = computed(() => !overview.value ? '正在检查连接配置' : overview.value.is_configured ? 'T+ 读取配置就绪' : '读取配置尚未就绪')
|
||||
const canCreate = computed(() => user.can('tplus:collect') && overview.value?.is_configured && !failed.value)
|
||||
const canConfigure = computed(() => user.can('tplus:collect') && automation.value?.editable && !failed.value)
|
||||
const switchReason = computed(() => !user.can('tplus:collect') ? user.permissionReason('tplus:collect') : !automation.value?.editable ? '连接配置或设置服务尚未就绪,请联系维护人员' : '开关保存后自动生效,当前读取任务不会中断')
|
||||
const alerts: Record<string, string> = {
|
||||
MIGRATION_REQUIRED:'采集设置尚未就绪,请联系维护人员检查服务。',
|
||||
SYNC_REQUIRES_ATTENTION:'最近的采集未全部完成,可在同步记录中查看原因并重试。',
|
||||
QUEUE_OVERDUE:'任务等待超过 15 分钟,请检查读取服务是否运行。',
|
||||
SYNC_STALE:'较长时间没有成功采集,请检查最近的任务。',
|
||||
WORKER_OFFLINE:'读取服务心跳中断,当前进度可能不再更新,请联系维护人员。',
|
||||
}
|
||||
function statusType(value: api.TaskStatus): 'success' | 'warning' | 'danger' | 'info' {
|
||||
return value === 'SUCCEEDED' ? 'success' : value === 'FAILED' ? 'danger' : value === 'PARTIAL' ? 'warning' : 'info'
|
||||
}
|
||||
function created(batch: api.TPlusDetail): void { submittedNumber.value = batch.batch_no; void refresh() }
|
||||
async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
if (retrying.value) return
|
||||
retrying.value = row.batch_no
|
||||
@ -47,89 +50,116 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
try {
|
||||
const batch = await api.retryTask(row.batch_no, key)
|
||||
retryKeys.delete(row.batch_no)
|
||||
ElMessage.success('重试任务已排队,原批次和异常记录已保留')
|
||||
detailNumber.value = batch.batch_no
|
||||
ElMessage.success('已创建重试任务,原记录继续保留')
|
||||
submittedNumber.value = batch.batch_no
|
||||
await refresh()
|
||||
} catch (error) { handledError(error) }
|
||||
finally { retrying.value = null }
|
||||
}
|
||||
function created(batch: api.TPlusDetail): void {
|
||||
detailNumber.value = batch.batch_no
|
||||
void refresh()
|
||||
}
|
||||
function statusType(value: api.TaskStatus): 'success' | 'warning' | 'danger' | 'info' {
|
||||
return value === 'SUCCEEDED' ? 'success' : value === 'FAILED' ? 'danger' : value === 'PARTIAL' ? 'warning' : 'info'
|
||||
}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tplus-page">
|
||||
<header class="page-head">
|
||||
<div><h1 class="page-title">T+ 数据同步</h1><p class="page-desc">读取源数据、检查关联,并追踪每次处理结果。</p><div class="waterline" /></div>
|
||||
<div class="head-actions"><el-button :loading="refreshing" @click="refresh">刷新</el-button><el-button type="primary" :disabled="!user.canWritePayroll || !overview?.is_configured || overviewFailed" @click="creating = true">新建任务</el-button></div>
|
||||
<div class="tplus-console">
|
||||
<header class="console-header">
|
||||
<div><h1>T+ 数据同步</h1><p>查看同步进度、管理自动采集,并核对每次读取结果。</p></div>
|
||||
<div class="console-actions"><el-button :loading="loading" @click="refresh()">刷新状态</el-button><el-button type="primary" :disabled="!canCreate" :title="user.permissionReason('tplus:collect')" @click="creating = true">立即同步</el-button></div>
|
||||
</header>
|
||||
<el-alert type="warning" title="Docker 恢复库用于开发。基础数据先预览再导入;核算预演与正式工资隔离。旧版 SQL Server 兼容性在正式交付前另行验证。" :closable="false" show-icon />
|
||||
<el-alert v-if="overviewFailed" class="config-note" type="error" title="概况加载失败,请点击刷新重试" :closable="false" />
|
||||
<p v-else class="config-note" role="status">{{ overview?.configuration_message ?? '正在加载配置状态…' }}</p>
|
||||
<section v-if="overview?.automation" class="card card-body automation-status" aria-label="自动采集与运行状态">
|
||||
<div class="automation-heading"><h2>自动采集与运行状态</h2><el-tag :type="overview.automation.enabled ? 'success' : 'info'">{{ overview.automation.stop_requested ? '已请求停止' : overview.automation.enabled ? '自动排队已启用' : '自动排队未启用' }}</el-tag></div>
|
||||
<p class="page-desc">每周{{ weekdays[overview.automation.weekday] }} {{ overview.automation.time }}(北京时间)检查采集;正式导入和工资生成仍需制作人确认。</p>
|
||||
<dl class="automation-grid"><div><dt>采集进程</dt><dd>{{ phases[overview.automation.worker_phase] ?? '待检查' }}</dd></div><div><dt>最近心跳</dt><dd>{{ formatDateTime(overview.automation.heartbeat_at) }}</dd></div><div><dt>最近成功采集</dt><dd>{{ formatDateTime(overview.automation.last_success_at) }}</dd></div><div><dt>下次有限重试</dt><dd>{{ formatDateTime(overview.automation.next_retry_at) }}</dd></div></dl>
|
||||
<el-alert v-for="alert in overview.automation.alerts" :key="alert.code" class="automation-alert" :title="alert.message" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
|
||||
<div class="connection-line"><span :class="{'is-connected': overview?.is_configured}">{{ connectionLabel }}</span><span>仅读取 T+ 数据</span><span v-if="refreshedAt">更新于 {{ refreshedAt.toLocaleTimeString('zh-CN', {hour12: false}) }}</span></div>
|
||||
<el-alert v-if="failed" class="connection-alert" title="状态暂时更新失败,已保留上次结果" description="请检查网络后刷新;当前显示的进度可能不是最新状态。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="overview && !overview.is_configured" class="connection-alert" title="暂时无法读取新数据" description="已有同步记录和资料仍可查看,请联系维护人员检查读取配置。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="submittedNumber" class="submitted-task" role="status"><span>已提交任务 <span class="cell-code">{{ submittedNumber }}</span>,处理结果会自动更新。</span><el-button link type="primary" @click="detailNumber = submittedNumber">查看已提交任务</el-button></div>
|
||||
|
||||
<!-- 网格为可伸缩主区 + 320px设置区 + 16px间距,窄屏合并单列,沿用现有全局导航。 -->
|
||||
<div class="sync-top-grid">
|
||||
<div class="console-panel progress-panel"><SyncProgress :batch="overview?.current_batch ?? null" @detail="detailNumber = $event" /></div>
|
||||
<section class="console-panel automation-panel" aria-labelledby="automation-title">
|
||||
<div class="automation-title"><div><p class="section-caption">同步计划</p><h2 id="automation-title">每周自动采集</h2></div><el-switch :model-value="!!automation?.enabled" :loading="saving" :disabled="!canConfigure" :title="switchReason" aria-label="自动采集开关" @change="changeAutomation" /></div>
|
||||
<p class="automation-state" role="status">{{ saving ? '正在保存设置…' : automation?.enabled ? '自动采集已开启' : '自动采集已关闭' }}</p>
|
||||
<dl class="schedule-details"><div><dt>采集时间</dt><dd>每周{{ weekdays[automation?.weekday ?? 0] }} {{ automation?.time ?? '02:00' }}<small>北京时间</small></dd></div><div><dt>读取服务</dt><dd>{{ phases[automation?.worker_phase ?? 'UNKNOWN'] }}</dd></div><div><dt>最近成功</dt><dd>{{ formatDateTime(automation?.last_success_at) }}</dd></div><div v-if="automation?.next_retry_at"><dt>下次重试</dt><dd>{{ formatDateTime(automation.next_retry_at) }}</dd></div></dl>
|
||||
<p class="settings-help">开启后按周读取;关闭只停止后续自动排队,当前任务继续完成。手动同步仍可使用。</p>
|
||||
<p v-if="!canConfigure" class="settings-help">{{ switchReason }}</p>
|
||||
</section>
|
||||
</div>
|
||||
<el-alert v-for="alert in automation?.alerts ?? []" :key="alert.code" class="connection-alert" :title="alerts[alert.code] ?? '采集状态需要检查,请查看相关任务。'" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
|
||||
|
||||
<div class="sync-boundary"><b>采集完成后,还需核对并导入业务</b><span>此处保存从 T+ 读取的数据;资料导入、绩效核算和工资生成分别执行。</span><el-button link @click="activeTab = 'workspace'">前往资料与导入</el-button></div>
|
||||
<section class="console-panel records-panel">
|
||||
<el-tabs v-model="activeTab" class="console-tabs">
|
||||
<el-tab-pane label="同步记录" name="records">
|
||||
<div class="records-toolbar"><div class="record-filters"><el-select v-model="status" clearable placeholder="全部状态" aria-label="批次状态" @change="search"><el-option v-for="(label, key) in statuses" :key="key" :label="label" :value="key" /></el-select><el-select v-model="taskType" clearable placeholder="全部任务" aria-label="任务类型" @change="search"><el-option label="数据读取" value="READ" /><el-option label="关联检查" value="AUDIT" /></el-select></div><span class="record-summary">共 {{ total }} 次任务<span v-if="overview"> · {{ (overview.status_counts.RUNNING ?? 0) + (overview.status_counts.QUEUED ?? 0) }} 次进行中</span></span></div>
|
||||
<el-table :data="records" class="sync-table" v-loading="loading && !records.length" empty-text="还没有同步记录,点击“立即同步”开始读取" aria-label="同步任务记录">
|
||||
<el-table-column label="同步任务" min-width="240"><template #default="{row}"><button class="batch-link" type="button" @click="detailNumber = row.batch_no">{{ row.batch_no }}</button><p class="row-note">{{ formatDateTime(row.create_time) }}<span class="trigger-label">{{ row.trigger_type === 'SCHEDULED' ? '每周自动采集' : row.trigger_type === 'AUTO_RETRY' ? `自动重试 ${row.automatic_attempt}/2` : '手动任务' }}</span></p></template></el-table-column>
|
||||
<el-table-column label="范围" min-width="165"><template #default="{row}"><span class="scope-copy">{{ scopeLabel(row) }}</span></template></el-table-column>
|
||||
<el-table-column label="进度 / 状态" min-width="150"><template #default="{row}"><el-tag :type="statusType(row.status)">{{ statuses[row.status as api.TaskStatus] }}</el-tag><p class="row-note">{{ row.progress?.percentage == null ? row.status === 'QUEUED' ? '等待开始' : `${row.source_record_count.toLocaleString()} 条已处理` : `${row.progress.percentage}% · ${row.source_record_count.toLocaleString()} 条` }}</p></template></el-table-column>
|
||||
<el-table-column label="新增 / 跳过" min-width="120" align="right"><template #default="{row}"><span class="tabular">{{ row.staged_record_count.toLocaleString() }} / {{ row.skipped_record_count.toLocaleString() }}</span></template></el-table-column>
|
||||
<el-table-column label="异常" width="72" align="right"><template #default="{row}"><span class="tabular" :class="{'error-count': row.failed_record_count > 0}">{{ row.failed_record_count.toLocaleString() }}</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right"><template #default="{row}"><el-button link type="primary" @click="detailNumber = row.batch_no">详情</el-button><el-button v-if="row.can_retry" link type="primary" :disabled="!user.can('tplus:retry') || !overview?.is_configured || failed || !!retrying" :loading="retrying === row.batch_no" @click="retry(row)" :title="user.permissionReason('tplus:retry')">重试</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="records-footer"><span>重复数据会跳过,历史任务和异常记录继续保留。</span><AppPagination :total="total" :page-num="pageNum" :page-size="pageSize" @page-change="onPageChange" @size-change="onSizeChange" /></div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="资料与导入" name="workspace"><WorkbenchPanel v-if="activeTab === 'workspace'" /></el-tab-pane>
|
||||
<el-tab-pane label="关联检查" name="audit"><div class="audit-content"><h2>最近一次关联检查</h2><AuditResult v-if="overview?.latest_audit" :report="overview.latest_audit" /><p v-else class="page-desc">还没有关联检查结果。点击“立即同步”,选择“关联检查”,可核对单据之间的对应关系。</p></div></el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
<div v-if="overview" class="task-counts" aria-label="任务数量概况"><span v-for="(label, key) in statuses" :key="key">{{ label }} <b class="cell-num">{{ (overview.status_counts[key] ?? 0).toLocaleString() }}</b></span><span>累计新增快照 <b>{{ overview.total_staged_records.toLocaleString() }}</b></span></div>
|
||||
<section class="batch-card" aria-label="同步批次">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="status" clearable placeholder="全部状态" aria-label="批次状态" @change="search"><el-option v-for="(label, key) in statuses" :key="key" :label="label" :value="key" /></el-select>
|
||||
<el-select v-model="taskType" clearable placeholder="全部任务" aria-label="任务类型" @change="search"><el-option label="读取数据" value="READ" /><el-option label="关联检查" value="AUDIT" /></el-select>
|
||||
</div>
|
||||
<div class="card card-body"><el-table v-loading="loading" :data="records" empty-text="暂无任务,可新建读取或关联检查" class="data-table">
|
||||
<el-table-column label="批次 / 创建时间" min-width="235"><template #default="{ row }"><el-button link type="primary" class="cell-code" @click="detailNumber = row.batch_no">{{ row.batch_no }}</el-button><p class="batch-time">{{ formatDateTime(row.create_time) }}</p></template></el-table-column>
|
||||
<el-table-column label="任务 / 范围" min-width="180"><template #default="{ row }"><b>{{ row.task_type === 'AUDIT' ? '关联检查' : '读取数据' }}</b><el-tag v-if="row.trigger_type === 'SCHEDULED'" size="small">每周采集</el-tag><el-tag v-if="row.trigger_type === 'AUTO_RETRY'" size="small" type="warning">自动重试 {{ row.automatic_attempt }}/2</el-tag><p class="batch-scope">{{ scopeLabel(row) }}</p></template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status as api.TaskStatus] ?? row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="source_record_count" label="源记录" width="100" align="right" class-name="cell-num" />
|
||||
<el-table-column prop="staged_record_count" label="新增" width="90" align="right" class-name="cell-num" />
|
||||
<el-table-column prop="skipped_record_count" label="重复跳过" width="100" align="right" class-name="cell-num" />
|
||||
<el-table-column prop="failed_record_count" label="异常" width="80" align="right" class-name="cell-num" />
|
||||
<el-table-column label="操作" width="140" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="detailNumber = row.batch_no">查看</el-button><el-button v-if="row.can_retry" link type="primary" :disabled="!user.canWritePayroll || !overview?.is_configured || overviewFailed || !!retrying" :loading="retrying === row.batch_no" @click="retry(row)">重试</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager-scroll"><AppPagination :total="total" :page-num="pageNum" :page-size="pageSize" @page-change="onPageChange" @size-change="onSizeChange" /></div></div>
|
||||
</section>
|
||||
<section class="card card-body latest-audit" aria-label="最近关联检查">
|
||||
<h2>最近关联检查</h2>
|
||||
<AuditResult v-if="overview?.latest_audit" :report="overview.latest_audit" />
|
||||
<p v-else class="page-desc">暂无关联检查结果。新建“关联检查”任务后,可在此查看覆盖数量与待确认口径。</p>
|
||||
</section>
|
||||
<WorkbenchPanel />
|
||||
<CreateTaskDialog v-model="creating" :configured="!!overview?.is_configured && !overviewFailed" @created="created" />
|
||||
<BatchDetailDialog v-model="detailNumber" />
|
||||
<CreateTaskDialog v-model="creating" :configured="!!canCreate" @created="created" />
|
||||
<BatchDetailDialog v-model="detailNumber" :current-batch-number="overview?.current_batch?.batch_no" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.head-actions { display: flex; gap: 8px; }
|
||||
.head-actions .el-button + .el-button { margin-left: 0; }
|
||||
.config-note { margin: 12px 0; color: var(--ys-ink-2); }
|
||||
.task-counts { display: flex; flex-wrap: wrap; gap: 12px 24px; margin: 16px 0; color: var(--ys-ink-2); }
|
||||
.task-counts b { color: var(--ys-ink); margin-left: 4px; }
|
||||
.batch-card { margin-top: 16px; }
|
||||
.filter-bar .el-select { width: 160px; }
|
||||
.batch-time, .batch-scope { margin: 4px 0 0; color: var(--ys-ink-2); font-size: 12px; }
|
||||
.latest-audit { margin-top: 20px; }
|
||||
.latest-audit h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.automation-status { margin-top: 16px; }
|
||||
.automation-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.automation-heading h2 { font-size: 16px; margin: 0; }
|
||||
.automation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
||||
.automation-grid dt { color: var(--ys-ink-2); font-size: 12px; }
|
||||
.automation-grid dd { margin: 8px 0 0; font-variant-numeric: tabular-nums; }
|
||||
.automation-alert { margin-top: 8px; }
|
||||
.pager-scroll { overflow-x: auto; }
|
||||
@media (max-width: 640px) {
|
||||
.page-head { align-items: flex-start; gap: 16px; flex-direction: column; }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
.filter-bar .el-select { width: 100%; }
|
||||
}
|
||||
.tplus-console { --sync-gap: 16px; color: var(--ys-ink); }
|
||||
.console-header { display: flex; align-items: center; justify-content: space-between; gap: var(--sync-gap); margin-bottom: 14px; }
|
||||
.console-header h1 { font-size: 24px; line-height: 1.4; font-weight: 600; margin: 0; }
|
||||
.console-header p { font-size: 14px; line-height: 1.7; color: var(--ys-ink-2); margin: 6px 0 0; }
|
||||
.console-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.console-actions .el-button + .el-button { margin: 0; }
|
||||
.connection-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 20px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.connection-line .is-connected { color: var(--el-color-success-dark-2); }
|
||||
.sync-top-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--sync-gap); align-items: start; }
|
||||
.console-panel { background: var(--ys-paper); border: 1px solid var(--ys-line); border-radius: 10px; box-shadow: none; min-width: 0; }
|
||||
.progress-panel, .automation-panel { display: flex; flex-direction: column; padding: 22px 24px; }
|
||||
.automation-title { display: flex; align-items: center; justify-content: space-between; gap: var(--sync-gap); }
|
||||
.automation-title h2, .audit-content h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.section-caption { font-size: 13px; color: var(--ys-ink-2); margin: 0 0 6px; }
|
||||
.automation-state { margin: 12px 0 18px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.automation-panel :deep(.el-switch) { --el-switch-on-color: var(--ys-ok); }
|
||||
.schedule-details { margin: 0; flex: 1; align-content: start; }
|
||||
.schedule-details > div { display: grid; grid-template-columns: 70px minmax(0,1fr); align-items: baseline; gap: 8px; margin: 12px 0; font-size: 13px; }
|
||||
.schedule-details dt { color: var(--ys-ink-2); }
|
||||
.schedule-details dd { margin: 0; line-height: 1.65; font-variant-numeric: tabular-nums; }
|
||||
.schedule-details small { margin-left: 6px; color: var(--ys-ink-2); font-size: 13px; }
|
||||
.settings-help { font-size: 13px; line-height: 1.8; color: var(--ys-ink-2); margin: 18px 0 0; }
|
||||
.sync-boundary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 14px; padding: 18px 2px; font-size: 13px; line-height: 1.7; }
|
||||
.sync-boundary b { font-weight: 500; }
|
||||
.sync-boundary span { color: var(--ys-ink-2); }
|
||||
.sync-boundary .el-button { font-size: 13px; height: auto; }
|
||||
.records-panel { padding: 0 20px 16px; }
|
||||
.console-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
|
||||
.console-tabs :deep(.el-tabs__item) { height: 48px; }
|
||||
.console-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: var(--ys-line); }
|
||||
.records-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
|
||||
.record-filters { display: flex; gap: 10px; }
|
||||
.record-filters .el-select { width: 150px; }
|
||||
.record-summary { font-size: 13px; color: var(--ys-ink-2); font-variant-numeric: tabular-nums; }
|
||||
.sync-table :deep(th.el-table__cell) { background: var(--ys-paper); font-size: 13px; font-weight: 500; }
|
||||
.sync-table :deep(td.el-table__cell) { padding: 14px 0; }
|
||||
.batch-link { padding: 4px 0; border: 0; background: none; font: 13px var(--ys-font-brand); font-variant-numeric: tabular-nums; color: var(--ys-indigo); cursor: pointer; text-align: left; overflow-wrap: anywhere; }
|
||||
.batch-link:focus-visible { outline: 2px solid var(--ys-indigo); outline-offset: 2px; }
|
||||
.row-note { margin: 5px 0 0; line-height: 1.6; font-size: 13px; color: var(--ys-ink-2); font-variant-numeric: tabular-nums; }
|
||||
.trigger-label { margin-left: 10px; }
|
||||
.scope-copy { font-size: 13px; line-height: 1.8; }
|
||||
.tabular { font-variant-numeric: tabular-nums; font-size: 13px; }
|
||||
.error-count { color: var(--el-color-danger-dark-2); font-weight: 500; }
|
||||
.records-footer { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; padding-top: 16px; }
|
||||
.records-footer > span { font-size: 13px; color: var(--ys-ink-2); }
|
||||
.records-footer :deep(.app-pagination) { margin: 0; }
|
||||
.connection-alert { margin: 12px 0; }
|
||||
.submitted-task { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 16px; margin: 0 0 16px; font-size: 13px; line-height: 1.8; color: var(--ys-ink-2); }
|
||||
.submitted-task > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.audit-content { padding: 8px 0 16px; }
|
||||
.audit-content h2 { margin-bottom: 14px; }
|
||||
.console-tabs :deep(.workbench) { border: 0; box-shadow: none; padding: 8px 0; margin: 0; }
|
||||
@media (max-width: 1080px) { .sync-top-grid { grid-template-columns: minmax(0,1fr) 290px; } .progress-panel, .automation-panel { padding: 20px; } }
|
||||
@media (max-width: 820px) { .sync-top-grid { grid-template-columns: minmax(0,1fr); } .schedule-details { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 20px; } .settings-help { margin-top: 10px; } }
|
||||
@media (max-width: 540px) { .console-header { align-items: flex-start; flex-direction: column; } .console-header h1 { font-size: 22px; } .console-actions { width: 100%; } .console-actions .el-button { flex: 1; } .record-filters { width: 100%; } .record-filters .el-select { width: 50%; } .records-panel { padding: 0 12px 12px; } .progress-panel, .automation-panel { padding: 18px 16px; } .schedule-details { grid-template-columns: minmax(0,1fr); } .records-footer { overflow-x: auto; } }
|
||||
</style>
|
||||
|
||||
@ -12,6 +12,6 @@ export const statuses: Record<TaskStatus, string> = {
|
||||
}
|
||||
/** 历史范围未记录时保持未知,不默认成全部。 */
|
||||
export function scopeLabel(row: TPlusBatch): string {
|
||||
if (row.task_type === 'AUDIT') return '关联与枚举检查'
|
||||
if (row.task_type === 'AUDIT') return '单据关联检查'
|
||||
return row.entity_types?.map((key) => entities.find((item) => item.value === key)?.label ?? key).join('、') || '历史批次,范围未记录'
|
||||
}
|
||||
|
||||
88
frontend/src/views/system/tplus/useSyncPage.ts
Normal file
88
frontend/src/views/system/tplus/useSyncPage.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/** 页面与筛选使用同一请求序号,后台刷新保留已显示数据和交互位置。 */
|
||||
export function useSyncPage() {
|
||||
const overview = ref<api.TPlusOverview | null>(null)
|
||||
const records = ref<api.TPlusBatch[]>([])
|
||||
const total = ref(0), pageNum = ref(1), pageSize = ref(10)
|
||||
const status = ref<api.TaskStatus>(), taskType = ref<api.TaskType>()
|
||||
const loading = ref(false), failed = ref(false), saving = ref(false)
|
||||
const refreshedAt = ref<Date | null>(null)
|
||||
let shownPage = 1, shownSize = 10, sequence = 0, alive = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let controller: AbortController | undefined
|
||||
|
||||
function schedule(): void {
|
||||
clearTimeout(timer)
|
||||
if (!alive) return
|
||||
const active = ['RUNNING', 'QUEUED'].includes(overview.value?.current_batch?.status ?? '')
|
||||
timer = setTimeout(() => {
|
||||
if (document.hidden || saving.value) schedule()
|
||||
else void refresh(true)
|
||||
}, active ? 5000 : 30000)
|
||||
}
|
||||
|
||||
async function refresh(background = false): Promise<void> {
|
||||
if (!alive) return
|
||||
if (saving.value) { schedule(); return }
|
||||
const current = ++sequence
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
if (!background) loading.value = true
|
||||
const query = {pageNum: pageNum.value, pageSize: pageSize.value, status: status.value, task_type: taskType.value}
|
||||
const options = {silentError: background, signal: controller.signal}
|
||||
try {
|
||||
const [summary, page] = await Promise.all([api.getOverview(options), api.listBatches(query, options)])
|
||||
if (!alive || current !== sequence) return
|
||||
overview.value = summary
|
||||
records.value = page.list
|
||||
total.value = page.total
|
||||
shownPage = query.pageNum
|
||||
shownSize = query.pageSize
|
||||
failed.value = false
|
||||
refreshedAt.value = new Date()
|
||||
} catch (error) {
|
||||
if (!alive || current !== sequence) return
|
||||
failed.value = true
|
||||
if (!background) {
|
||||
pageNum.value = shownPage
|
||||
pageSize.value = shownSize
|
||||
handledError(error)
|
||||
}
|
||||
} finally {
|
||||
if (current === sequence) { loading.value = false; schedule() }
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAutomation(value: boolean | string | number): Promise<void> {
|
||||
const config = overview.value?.automation
|
||||
if (typeof value !== 'boolean' || !config || saving.value) return
|
||||
saving.value = true
|
||||
++sequence
|
||||
controller?.abort()
|
||||
loading.value = false
|
||||
try {
|
||||
const result = await api.updateAutomation({enabled: value, expected_version: config.version})
|
||||
if (!alive || !overview.value) return
|
||||
overview.value.automation = result
|
||||
ElMessage.success(value ? '自动采集已开启' : '自动采集已关闭,当前任务继续完成')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
if (alive) await refresh(true)
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void { pageNum.value = 1; void refresh() }
|
||||
function onPageChange(value: number): void { pageNum.value = value; void refresh() }
|
||||
function onSizeChange(value: number): void { pageSize.value = value; pageNum.value = 1; void refresh() }
|
||||
function visible(): void { if (!document.hidden && !saving.value) void refresh(true) }
|
||||
onMounted(() => { alive = true; document.addEventListener('visibilitychange', visible); void refresh() })
|
||||
onUnmounted(() => { alive = false; ++sequence; clearTimeout(timer); controller?.abort(); document.removeEventListener('visibilitychange', visible) })
|
||||
return {overview, records, total, pageNum, pageSize, status, taskType, loading, failed,
|
||||
saving, refreshedAt, refresh, changeAutomation, search, onPageChange, onSizeChange}
|
||||
}
|
||||
@ -2,6 +2,7 @@ export const factKinds = { PERSONNEL: '人员名单', PRODUCT: '产品分类与
|
||||
export const workKinds = { MASTER_PREVIEW: '基础导入预览', MASTER_APPLY: '基础数据导入', CALC_PREVIEW: '核算预演', BUSINESS_PREVIEW: '正式业务导入预览', BUSINESS_APPLY: '正式业务导入' }
|
||||
export const actions: Record<string, string> = { EXCLUDED: '不纳入本次预演', CREATE: '新增', UPDATE: '更新', SKIP: '跳过', PENDING: '待补充', CONFLICT: '冲突', READY: '可核对', LEGACY: '旧应收冲平', WAITING_SHIPMENT: '预收待发货', QUEUED: '排队中', RUNNING: '处理中', SUCCEEDED: '处理完成', PARTIAL: '部分待处理', FAILED: '失败' }
|
||||
export const reasons: Record<string, string> = {
|
||||
TASK_AUTHORIZATION_REVOKED: '请求账号已停用或权限已撤销,请重新授权后创建任务',
|
||||
CUSTOMER_SCOPE_CONFLICT: '同一客户的期初或历史范围不一致,请先合并核对资料',
|
||||
GUIDANCE_FINALIZATION_REQUIRED: '发货月份指导成本尚未录入完毕并封存',
|
||||
CUSTOMER_SCOPE_ALREADY_SELECTED: '该客户已纳入本次平账,重复选择已跳过',
|
||||
@ -16,27 +17,27 @@ export const reasons: Record<string, string> = {
|
||||
BUSINESS_ROLE_MISSING: '系统岗位不存在', PRODUCT_CATEGORY_PENDING: '产品类型待填写',
|
||||
PRODUCT_UNIT_PENDING: '产品单位待核对', NOT_CUSTOMER_ONLY: '往来单位性质不是仅客户',
|
||||
CUSTOMER_ATTRIBUTE_PENDING: '客户属性或负责人待核对', TARGET_MISSING: '原导入目标已不存在',
|
||||
TARGET_UNIQUE_CONFLICT: '与现有数据重名或唯一键冲突', DUPLICATE_SOURCE_IDENTITY: '源数据身份重复',
|
||||
TARGET_UNIQUE_CONFLICT: '与现有人员或档案重复,请核对后处理', DUPLICATE_SOURCE_IDENTITY: '源数据身份重复',
|
||||
ROSTER_SOURCE_MISSING: '名单中的工号未在T+找到', PREVIEW_STALE: '数据或资料已变化,请重新预览',
|
||||
SOURCE_REFRESH_REQUIRED: '请先完整读取相关源数据', SOURCE_CHANGED: '源账套配置已变化',
|
||||
INTERRUPTED: '任务被中断,请重新创建任务', EXECUTION_ERROR: '执行失败,请核对配置和输入',
|
||||
GROUP_SCOPE_PENDING: '平账范围待明确', OPENING_BALANCE_PENDING: '期初应收待核实',
|
||||
HISTORICAL_SETTLEMENT_PENDING: '历史已核算及已发放款待核实',
|
||||
VOUCHER_STATE_PENDING: '收款尚未核实已生成凭证', GUIDANCE_PENDING: '缺少对应指导成本',
|
||||
GUIDANCE_MONTH_BASIS_PENDING: '指导价取发货月还是回款月尚未明确',
|
||||
AUGUST_RULE_PENDING: '2026年8月后的调整参数尚未确认', PERSONNEL_PENDING: '归属人员待确认',
|
||||
GUIDANCE_MONTH_BASIS_PENDING: '请补充该批发货所属月份的指导成本',
|
||||
AUGUST_RULE_PENDING: '缺少该核算日期适用的计提参数,请核对参数生效日期', PERSONNEL_PENDING: '归属人员待确认',
|
||||
CUSTOMER_CATEGORY_PENDING: '客户分类待确认', PARAMETERS_PENDING: '计提参数待补充',
|
||||
NEGATIVE_OR_ZERO_DOCUMENT: '负数或零金额单据需单独核对', NEGATIVE_EVENT_REVIEW_REQUIRED: '更正、红冲或作废业务需人工核对',
|
||||
LEGACY_SYSTEM_CALCULATES: '此款只冲旧应收,绩效由原软件计算', NO_ACTUAL_SHIPMENT: '尚无实际发货,不计提绩效',
|
||||
LEGACY_SYSTEM_CALCULATES: '此款仅冲抵期初应收,不参与本次绩效计提', NO_ACTUAL_SHIPMENT: '尚无实际发货,不计提绩效',
|
||||
FREIGHT_PENDING: '含税销售额是否已剔除运费待核实', SHIPPING_OWNERSHIP_PENDING: '发货所属业务和人员待核实',
|
||||
HANDOVER_SHIPMENTS_PENDING: '在职交接的发货归属待确认', TAX_AMOUNT_PENDING: '源含税金额缺失',
|
||||
GROUP_SOURCE_MISSING: '所选单据未在本次完整快照中找到', GROUP_CUSTOMER_MISMATCH: '平账组包含其他客户的单据',
|
||||
SHIPMENT_DETAILS_MISSING: '发货明细不完整', OVERLAPPING_GROUPS: '单据被多个平账组重复选取',
|
||||
AMOUNT_PRECISION_PENDING: '金额精度超过分,需核对舍入口径', DATA_VALUE_INVALID: '源日期或金额格式需核对',
|
||||
REPLAY_UNAVAILABLE: '该记录缺少可重跑快照或规则版本已变化,请保留原报告并新建预演',
|
||||
REPLAY_UNAVAILABLE: '无法按原依据重新核对,请保留原报告并重新生成预演',
|
||||
QUANTITY_PENDING: '单位成本对应的发货数量需核对',
|
||||
GROUP_FACT_REQUIRED: '尚未登记该平账组',
|
||||
PREPAYMENT_PARAMETERS_PENDING: '预收业务采用发货日还是收款日参数待确认',
|
||||
PREPAYMENT_PARAMETERS_PENDING: '请核对预收款到账日期及适用的计提参数',
|
||||
HANDOVER_PENDING: '离职日期或接手依据待补,不能猜测归属',
|
||||
ACCOUNTING_VOUCHER_INVALID: '关联凭证缺失、已作废或制单状态异常',
|
||||
VOUCHER_EVIDENCE_CONFLICT: '凭证关联与人工核实不一致,请复核',
|
||||
@ -48,5 +49,5 @@ export const reasons: Record<string, string> = {
|
||||
}
|
||||
export function reasonText(value: unknown): string {
|
||||
const parts = Array.isArray(value) ? value : String(value ?? '').split(',')
|
||||
return parts.filter(Boolean).map(v => reasons[String(v)] ?? String(v)).join(';')
|
||||
return parts.filter(Boolean).map(v => reasons[String(v)] ?? (/^[A-Z][A-Z0-9_]+$/.test(String(v)) ? '处理未完成,请查看任务详情或联系管理员' : String(v))).join(';')
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import * as userApi from '@/api/users'
|
||||
import type { UserItem, UserQueryParams } from '@/api/users'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import PermissionDrawer from '@/components/authorization/PermissionDrawer.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@ -27,6 +28,9 @@ import { passwordRule } from '@/utils/validate'
|
||||
* NULL),前端无需特殊处理
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
const permissionUser = ref<UserItem | null>(null)
|
||||
const permissionOpen = ref(false)
|
||||
function openPermissions(row: UserItem): void { permissionUser.value = row; permissionOpen.value = true }
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
// ==================== 列表 ====================
|
||||
@ -149,7 +153,6 @@ async function handleSubmit(): Promise<void> {
|
||||
real_name: form.real_name,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
roles: form.roles,
|
||||
remark: form.remark,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
@ -254,7 +257,7 @@ onMounted(() => {
|
||||
<!-- 页面头:标题 + 一句话说清「管什么、动它会波及什么」+ 签名水线(每页仅此一处) -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">用户管理</h1>
|
||||
<p class="page-desc">管登录账号与角色分配;禁用、改密或重置密码都会让该用户已登录的会话立即失效。</p>
|
||||
<p class="page-desc">管理登录账号并分配角色;用户自动继承角色权限,功能范围在角色管理中统一维护。</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
|
||||
@ -338,7 +341,7 @@ onMounted(() => {
|
||||
type="info"
|
||||
disable-transitions
|
||||
>
|
||||
{{ role }}
|
||||
{{ roleOptions.find(item => item.role_code === role)?.role_name ?? role }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -351,9 +354,10 @@ onMounted(() => {
|
||||
<el-table-column label="最后登录" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="330" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openPermissions(row)">分配角色</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openReset(row)">
|
||||
重置密码
|
||||
@ -397,6 +401,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PermissionDrawer v-model="permissionOpen" :id="permissionUser?.id ?? null" :name="permissionUser?.username" kind="user" @saved="load" />
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
@ -429,7 +434,7 @@ onMounted(() => {
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roles">
|
||||
<el-form-item v-if="!isEdit" label="角色" prop="roles">
|
||||
<el-select v-model="form.roles" multiple placeholder="至少选择一个角色" style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user