feat: 完成通知主通道与角色工作台收口
This commit is contained in:
parent
920fa1289d
commit
149d4510df
@ -0,0 +1,30 @@
|
||||
-- Web 会话迁移到现有 MySQL,后端重启后本地登录、钉钉免登和公开盘点会话仍可恢复。
|
||||
CREATE TABLE `web_session` (
|
||||
`sid` VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`data` LONGTEXT NOT NULL,
|
||||
`expires_at` DATETIME(3) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`sid`),
|
||||
INDEX `web_session_expires_at_idx` (`expires_at`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 每个钉钉投递失败异常只生成一次站内待处理记录。
|
||||
ALTER TABLE `dingtalk_delivery`
|
||||
ADD COLUMN `failure_notified_at` DATETIME(3) NULL;
|
||||
|
||||
-- 旧失败任务已在历史设置页中可查,本次上线不把它们重新制造成未读告警。
|
||||
UPDATE `dingtalk_delivery`
|
||||
SET `failure_notified_at` = COALESCE(`updated_at`, `created_at`, CURRENT_TIMESTAMP(3))
|
||||
WHERE `status` = 'failed';
|
||||
|
||||
-- 历史组织同步消息保留为系统记录,但不再继续制造未读红点。
|
||||
UPDATE `user_notification` user_notification
|
||||
INNER JOIN `notification` notification
|
||||
ON notification.`id` = user_notification.`notification_id`
|
||||
SET
|
||||
user_notification.`is_read` = 1,
|
||||
user_notification.`read_at` = COALESCE(user_notification.`read_at`, CURRENT_TIMESTAMP(3))
|
||||
WHERE notification.`type` = 'hr_sync'
|
||||
AND user_notification.`is_read` = 0;
|
||||
@ -750,6 +750,7 @@ model DingTalkDelivery {
|
||||
payload_json String @db.Text
|
||||
last_error_code String? @db.VarChar(100)
|
||||
last_error_message String? @db.Text
|
||||
failure_notified_at DateTime?
|
||||
sent_at DateTime?
|
||||
read_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
@ -766,6 +767,17 @@ model DingTalkDelivery {
|
||||
@@map("dingtalk_delivery")
|
||||
}
|
||||
|
||||
model WebSession {
|
||||
sid String @id @db.VarChar(191)
|
||||
data String @db.LongText
|
||||
expires_at DateTime
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@index([expires_at])
|
||||
@@map("web_session")
|
||||
}
|
||||
|
||||
// 维修记录
|
||||
model MaintenanceRecord {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
@ -109,6 +109,7 @@ test('未取得中心任务ID时复用原行和原幂等键恢复投递', async
|
||||
assert.equal(createCount, 0)
|
||||
assert.equal(updateArgs.where.dingtalk_task_id, null)
|
||||
assert.equal(updateArgs.data.status, 'pending')
|
||||
assert.equal(updateArgs.data.failure_notified_at, null)
|
||||
assert.equal(updateArgs.data.last_manual_action_by, 99)
|
||||
assert.equal(updateArgs.data.last_manual_action_type, 'resume_submit')
|
||||
const resumePayload = JSON.parse(updateArgs.data.payload_json)
|
||||
@ -207,6 +208,7 @@ test('状态查询错误时恢复原中心任务回查且绝不新建补发消
|
||||
assert.equal(createCount, 0)
|
||||
assert.equal(updateArgs.where.dingtalk_task_id, 'center-task-5')
|
||||
assert.equal(updateArgs.data.status, 'pending')
|
||||
assert.equal(updateArgs.data.failure_notified_at, null)
|
||||
assert.equal(updateArgs.data.last_manual_action_by, 99)
|
||||
assert.equal(updateArgs.data.last_manual_action_type, 'resume_status_query')
|
||||
assert.equal(
|
||||
|
||||
@ -355,6 +355,7 @@ test('驳回报废时按每个 item.pre_status 分别恢复', async () => {
|
||||
test('行政已处理列表包含全部已审批、已驳回和已完成记录,不按处理人收窄', async () => {
|
||||
const originalFindMany = prisma.disposalRecord.findMany
|
||||
const originalCount = prisma.disposalRecord.count
|
||||
const originalGroupBy = prisma.disposalRecord.groupBy
|
||||
let capturedWhere: any = null
|
||||
|
||||
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
|
||||
@ -367,6 +368,9 @@ test('行政已处理列表包含全部已审批、已驳回和已完成记录
|
||||
}]
|
||||
}
|
||||
;(prisma.disposalRecord.count as any) = async () => 1
|
||||
;(prisma.disposalRecord.groupBy as any) = async () => [
|
||||
{ status: 'completed', _count: { _all: 1 } }
|
||||
]
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse('administrative')
|
||||
@ -385,12 +389,14 @@ test('行政已处理列表包含全部已审批、已驳回和已完成记录
|
||||
} finally {
|
||||
;(prisma.disposalRecord.findMany as any) = originalFindMany
|
||||
;(prisma.disposalRecord.count as any) = originalCount
|
||||
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
|
||||
}
|
||||
})
|
||||
|
||||
test('IT即使请求all范围也只能查询本人发起的报废记录', async () => {
|
||||
const originalFindMany = prisma.disposalRecord.findMany
|
||||
const originalCount = prisma.disposalRecord.count
|
||||
const originalGroupBy = prisma.disposalRecord.groupBy
|
||||
let capturedWhere: any = null
|
||||
|
||||
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
|
||||
@ -398,6 +404,7 @@ test('IT即使请求all范围也只能查询本人发起的报废记录', async
|
||||
return []
|
||||
}
|
||||
;(prisma.disposalRecord.count as any) = async () => 0
|
||||
;(prisma.disposalRecord.groupBy as any) = async () => []
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse('it')
|
||||
@ -411,6 +418,7 @@ test('IT即使请求all范围也只能查询本人发起的报废记录', async
|
||||
} finally {
|
||||
;(prisma.disposalRecord.findMany as any) = originalFindMany
|
||||
;(prisma.disposalRecord.count as any) = originalCount
|
||||
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import prisma from '../../utils/prisma'
|
||||
import { getMyAssets } from '../myAssetController'
|
||||
|
||||
const createResponse = () => {
|
||||
const state = { statusCode: 200, body: null as any }
|
||||
const res = {
|
||||
locals: { user: { id: 1, role: 'user', person_id: 8 } },
|
||||
status(code: number) {
|
||||
state.statusCode = code
|
||||
return this
|
||||
},
|
||||
json(payload: any) {
|
||||
state.body = payload
|
||||
return this
|
||||
}
|
||||
}
|
||||
return { res, state }
|
||||
}
|
||||
|
||||
test('本人资产同时返回当前递归配件和借出时递归快照并标识关系变化', async () => {
|
||||
const originalPersonFindUnique = prisma.person.findUnique
|
||||
const originalBorrowFindMany = prisma.borrowRecord.findMany
|
||||
const originalEquipmentFindMany = prisma.equipment.findMany
|
||||
|
||||
const child = {
|
||||
id: 21,
|
||||
equipment_code: 'JS-RAM-0021',
|
||||
equipment_name: '内存',
|
||||
equipment_model: '32G',
|
||||
equipment_type: '内存条',
|
||||
status: '已借出',
|
||||
parent_id: 20,
|
||||
mount_status: 'mounted',
|
||||
department_id: 3,
|
||||
storage_location_id: 1
|
||||
}
|
||||
const grandchild = {
|
||||
...child,
|
||||
id: 22,
|
||||
equipment_code: 'JS-SSD-0022',
|
||||
equipment_name: '固态硬盘',
|
||||
equipment_model: '1T',
|
||||
equipment_type: '固态硬盘',
|
||||
parent_id: 21
|
||||
}
|
||||
|
||||
;(prisma.person.findUnique as any) = async () => ({
|
||||
id: 8,
|
||||
real_name: '测试员工',
|
||||
nickname: '魔礼红',
|
||||
job_number: 'E0008',
|
||||
status: 'active',
|
||||
source_employee_status: '在职',
|
||||
mainDepartment: { id: 3, name: '信息部' }
|
||||
})
|
||||
;(prisma.borrowRecord.findMany as any) = async () => [{
|
||||
id: 701,
|
||||
equipment_id: 20,
|
||||
borrow_time: new Date('2026-07-01T00:00:00Z'),
|
||||
expected_return_time: null,
|
||||
is_long_term: true,
|
||||
notes: null,
|
||||
children_snapshot: JSON.stringify([{
|
||||
id: 21,
|
||||
equipment_code: child.equipment_code,
|
||||
equipment_name: child.equipment_name,
|
||||
equipment_model: child.equipment_model,
|
||||
equipment_type: child.equipment_type,
|
||||
parent_id: 20,
|
||||
depth: 1
|
||||
}]),
|
||||
equipment: {
|
||||
id: 20,
|
||||
equipment_code: 'JS-PC-0020',
|
||||
equipment_name: '主机',
|
||||
children: [child],
|
||||
storageLocation: { id: 1, name: '仓库', code: 'WH' },
|
||||
department: { id: 3, name: '信息部' }
|
||||
}
|
||||
}]
|
||||
;(prisma.equipment.findMany as any) = async (args: any) => {
|
||||
const parentIds = args.where.parent_id.in as number[]
|
||||
if (parentIds.includes(20)) return [child]
|
||||
if (parentIds.includes(21)) return [grandchild]
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse()
|
||||
await getMyAssets({ query: {} } as any, res as any)
|
||||
|
||||
assert.equal(state.statusCode, 200)
|
||||
const asset = state.body.data.assets[0]
|
||||
assert.deepEqual(
|
||||
asset.current_accessories.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })),
|
||||
[
|
||||
{ id: 21, parent_id: 20, depth: 1 },
|
||||
{ id: 22, parent_id: 21, depth: 2 }
|
||||
]
|
||||
)
|
||||
assert.deepEqual(
|
||||
asset.borrowed_accessories_snapshot.map((item: any) => ({
|
||||
id: item.id,
|
||||
parent_id: item.parent_id,
|
||||
root_equipment_id: item.root_equipment_id,
|
||||
depth: item.depth
|
||||
})),
|
||||
[{ id: 21, parent_id: 20, root_equipment_id: 20, depth: 1 }]
|
||||
)
|
||||
assert.equal(asset.accessory_relationship_changed, true)
|
||||
assert.equal(asset.borrowed_accessories_snapshot_valid, true)
|
||||
assert.equal(asset.children.length, 1)
|
||||
assert.equal(asset.equipment.children.length, 1)
|
||||
} finally {
|
||||
;(prisma.person.findUnique as any) = originalPersonFindUnique
|
||||
;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany
|
||||
;(prisma.equipment.findMany as any) = originalEquipmentFindMany
|
||||
}
|
||||
})
|
||||
124
backend/src/controllers/__tests__/userController.list.test.ts
Normal file
124
backend/src/controllers/__tests__/userController.list.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import prisma from '../../utils/prisma'
|
||||
import { getPersonBindingOptions, getUserList } from '../userController'
|
||||
|
||||
const createResponse = () => {
|
||||
const state = { statusCode: 200, body: null as any }
|
||||
const res = {
|
||||
locals: { user: { id: 1, role: 'admin' } },
|
||||
status(code: number) {
|
||||
state.statusCode = code
|
||||
return this
|
||||
},
|
||||
json(payload: any) {
|
||||
state.body = payload
|
||||
return this
|
||||
}
|
||||
}
|
||||
return { res, state }
|
||||
}
|
||||
|
||||
test('用户列表服务端分页并支持关键词、角色和状态筛选', async () => {
|
||||
const originalFindMany = prisma.user.findMany
|
||||
const originalCount = prisma.user.count
|
||||
let capturedArgs: any = null
|
||||
|
||||
;(prisma.user.findMany as any) = async (args: any) => {
|
||||
capturedArgs = args
|
||||
return []
|
||||
}
|
||||
;(prisma.user.count as any) = async () => 23
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse()
|
||||
await getUserList({
|
||||
query: { page: '3', pageSize: '10', keyword: '魔礼红', role: 'it', status: 'active' }
|
||||
} as any, res as any)
|
||||
|
||||
assert.equal(state.statusCode, 200)
|
||||
assert.equal(capturedArgs.skip, 20)
|
||||
assert.equal(capturedArgs.take, 10)
|
||||
assert.equal(capturedArgs.where.role, 'it')
|
||||
assert.equal(capturedArgs.where.status, 'active')
|
||||
assert.match(JSON.stringify(capturedArgs.where.OR), /real_name|nickname|job_number/)
|
||||
assert.deepEqual(state.body.data, { list: [], total: 23, page: 3, pageSize: 10 })
|
||||
} finally {
|
||||
;(prisma.user.findMany as any) = originalFindMany
|
||||
;(prisma.user.count as any) = originalCount
|
||||
}
|
||||
})
|
||||
|
||||
test('用户列表缺省参数保持旧调用可用,非法筛选返回 422', async () => {
|
||||
const originalFindMany = prisma.user.findMany
|
||||
const originalCount = prisma.user.count
|
||||
;(prisma.user.findMany as any) = async () => []
|
||||
;(prisma.user.count as any) = async () => 0
|
||||
|
||||
try {
|
||||
const legacy = createResponse()
|
||||
await getUserList({ query: {} } as any, legacy.res as any)
|
||||
assert.equal(legacy.state.statusCode, 200)
|
||||
assert.equal(legacy.state.body.data.page, 1)
|
||||
assert.equal(legacy.state.body.data.pageSize, 10)
|
||||
|
||||
const invalid = createResponse()
|
||||
await getUserList({ query: { role: 'super-admin' } } as any, invalid.res as any)
|
||||
assert.equal(invalid.state.statusCode, 422)
|
||||
} finally {
|
||||
;(prisma.user.findMany as any) = originalFindMany
|
||||
;(prisma.user.count as any) = originalCount
|
||||
}
|
||||
})
|
||||
|
||||
test('员工绑定选项分页返回就绪和绑定状态但不泄露钉钉 userid', async () => {
|
||||
const originalFindMany = prisma.person.findMany
|
||||
const originalCount = prisma.person.count
|
||||
let capturedArgs: any = null
|
||||
|
||||
;(prisma.person.findMany as any) = async (args: any) => {
|
||||
capturedArgs = args
|
||||
return [{
|
||||
id: 8,
|
||||
real_name: '测试员工',
|
||||
nickname: '魔礼红',
|
||||
job_number: 'E0008',
|
||||
status: 'active',
|
||||
source_userid: 'secret-dingtalk-userid',
|
||||
mainDepartment: { id: 3, name: '信息部' },
|
||||
user: { id: 2, username: 'it-user', name: 'IT用户', role: 'it', status: 'active' }
|
||||
}]
|
||||
}
|
||||
;(prisma.person.count as any) = async () => 1
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse()
|
||||
await getPersonBindingOptions({
|
||||
query: {
|
||||
page: '1',
|
||||
pageSize: '20',
|
||||
keyword: '魔礼红',
|
||||
bound: 'bound',
|
||||
dingtalk_ready: 'true'
|
||||
}
|
||||
} as any, res as any)
|
||||
|
||||
assert.equal(state.statusCode, 200)
|
||||
assert.match(JSON.stringify(capturedArgs.where), /source_userid/)
|
||||
assert.match(JSON.stringify(capturedArgs.where), /isNot/)
|
||||
assert.equal(state.body.data.list[0].dingtalk_ready, true)
|
||||
assert.equal(state.body.data.list[0].is_bound, true)
|
||||
assert.deepEqual(state.body.data.list[0].bound_user, {
|
||||
id: 2,
|
||||
username: 'it-user',
|
||||
name: 'IT用户',
|
||||
role: 'it',
|
||||
status: 'active'
|
||||
})
|
||||
assert.equal(JSON.stringify(state.body).includes('secret-dingtalk-userid'), false)
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(state.body.data.list[0], 'source_userid'), false)
|
||||
} finally {
|
||||
;(prisma.person.findMany as any) = originalFindMany
|
||||
;(prisma.person.count as any) = originalCount
|
||||
}
|
||||
})
|
||||
130
backend/src/controllers/__tests__/workflowList.summary.test.ts
Normal file
130
backend/src/controllers/__tests__/workflowList.summary.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import prisma from '../../utils/prisma'
|
||||
import { getDisposalList } from '../disposalController'
|
||||
import { getMaintenanceList } from '../maintenanceController'
|
||||
|
||||
const createResponse = (role: string) => {
|
||||
const state = { statusCode: 200, body: null as any }
|
||||
const res = {
|
||||
locals: { user: { id: 6, role } },
|
||||
status(code: number) {
|
||||
state.statusCode = code
|
||||
return this
|
||||
},
|
||||
json(payload: any) {
|
||||
state.body = payload
|
||||
return this
|
||||
}
|
||||
}
|
||||
return { res, state }
|
||||
}
|
||||
|
||||
test('报废 summary 按角色范围和关键词统计但不受单个状态筛选影响', async () => {
|
||||
const originalFindMany = prisma.disposalRecord.findMany
|
||||
const originalCount = prisma.disposalRecord.count
|
||||
const originalGroupBy = prisma.disposalRecord.groupBy
|
||||
let listWhere: any = null
|
||||
let summaryWhere: any = null
|
||||
|
||||
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
|
||||
listWhere = args.where
|
||||
return []
|
||||
}
|
||||
;(prisma.disposalRecord.count as any) = async () => 2
|
||||
;(prisma.disposalRecord.groupBy as any) = async (args: any) => {
|
||||
summaryWhere = args.where
|
||||
return [
|
||||
{ status: 'pending', _count: { _all: 2 } },
|
||||
{ status: 'approved', _count: { _all: 1 } },
|
||||
{ status: 'completed', _count: { _all: 4 } }
|
||||
]
|
||||
}
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse('it')
|
||||
await getDisposalList({
|
||||
query: { scope: 'all', status: 'pending', keyword: '主机', page: '2', pageSize: '20' }
|
||||
} as any, res as any)
|
||||
|
||||
assert.equal(state.statusCode, 200)
|
||||
assert.match(JSON.stringify(listWhere), /pending/)
|
||||
assert.doesNotMatch(JSON.stringify(summaryWhere), /"status":"pending"/)
|
||||
assert.match(JSON.stringify(summaryWhere), /"applied_by":6/)
|
||||
assert.match(JSON.stringify(summaryWhere), /主机/)
|
||||
assert.deepEqual(state.body.data.summary, {
|
||||
total: 7,
|
||||
pending: 2,
|
||||
approved: 1,
|
||||
rejected: 0,
|
||||
completed: 4
|
||||
})
|
||||
assert.equal(state.body.data.total, 2)
|
||||
assert.equal(state.body.data.page, 2)
|
||||
assert.equal(state.body.data.pageSize, 20)
|
||||
} finally {
|
||||
;(prisma.disposalRecord.findMany as any) = originalFindMany
|
||||
;(prisma.disposalRecord.count as any) = originalCount
|
||||
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
|
||||
}
|
||||
})
|
||||
|
||||
test('维修 summary 按关键词和资产统计但不受单个状态筛选影响', async () => {
|
||||
const originalFindMany = prisma.maintenanceRecord.findMany
|
||||
const originalCount = prisma.maintenanceRecord.count
|
||||
const originalGroupBy = prisma.maintenanceRecord.groupBy
|
||||
let listWhere: any = null
|
||||
let summaryWhere: any = null
|
||||
|
||||
;(prisma.maintenanceRecord.findMany as any) = async (args: any) => {
|
||||
listWhere = args.where
|
||||
return []
|
||||
}
|
||||
;(prisma.maintenanceRecord.count as any) = async () => 1
|
||||
;(prisma.maintenanceRecord.groupBy as any) = async (args: any) => {
|
||||
summaryWhere = args.where
|
||||
return [
|
||||
{ status: 'in_progress', _count: { _all: 1 } },
|
||||
{ status: 'completed', _count: { _all: 3 } },
|
||||
{ status: 'cancelled', _count: { _all: 2 } }
|
||||
]
|
||||
}
|
||||
|
||||
try {
|
||||
const { res, state } = createResponse('it')
|
||||
await getMaintenanceList({
|
||||
query: { status: 'in_progress', keyword: '无法开机', equipment_id: '10' }
|
||||
} as any, res as any)
|
||||
|
||||
assert.equal(state.statusCode, 200)
|
||||
assert.match(JSON.stringify(listWhere), /in_progress/)
|
||||
assert.doesNotMatch(JSON.stringify(summaryWhere), /in_progress/)
|
||||
assert.match(JSON.stringify(summaryWhere), /无法开机/)
|
||||
assert.equal(summaryWhere.equipment_id, 10)
|
||||
assert.deepEqual(state.body.data.summary, {
|
||||
total: 6,
|
||||
in_progress: 1,
|
||||
completed: 3,
|
||||
cancelled: 2
|
||||
})
|
||||
assert.equal(state.body.data.total, 1)
|
||||
} finally {
|
||||
;(prisma.maintenanceRecord.findMany as any) = originalFindMany
|
||||
;(prisma.maintenanceRecord.count as any) = originalCount
|
||||
;(prisma.maintenanceRecord.groupBy as any) = originalGroupBy
|
||||
}
|
||||
})
|
||||
|
||||
test('流程列表非法筛选给出明确 422,维修越权给出 403', async () => {
|
||||
const disposal = createResponse('admin')
|
||||
await getDisposalList({ query: { page: '0' } } as any, disposal.res as any)
|
||||
assert.equal(disposal.state.statusCode, 422)
|
||||
|
||||
const maintenanceStatus = createResponse('it')
|
||||
await getMaintenanceList({ query: { status: 'broken' } } as any, maintenanceStatus.res as any)
|
||||
assert.equal(maintenanceStatus.state.statusCode, 422)
|
||||
|
||||
const maintenanceRole = createResponse('administrative')
|
||||
await getMaintenanceList({ query: {} } as any, maintenanceRole.res as any)
|
||||
assert.equal(maintenanceRole.state.statusCode, 403)
|
||||
})
|
||||
@ -304,6 +304,7 @@ export const retryDingTalkDelivery = async (req: Request, res: Response) => {
|
||||
lease_expires_at: null,
|
||||
last_error_code: null,
|
||||
last_error_message: null,
|
||||
failure_notified_at: null,
|
||||
last_manual_action_by: operatorUserId,
|
||||
last_manual_action_type: 'resume_submit',
|
||||
last_manual_action_at: manualActionAt,
|
||||
@ -337,6 +338,7 @@ export const retryDingTalkDelivery = async (req: Request, res: Response) => {
|
||||
lease_expires_at: null,
|
||||
last_error_code: null,
|
||||
last_error_message: null,
|
||||
failure_notified_at: null,
|
||||
last_manual_action_by: operatorUserId,
|
||||
last_manual_action_type: 'resume_status_query',
|
||||
last_manual_action_at: manualActionAt,
|
||||
|
||||
@ -202,6 +202,22 @@ const getCurrentUser = (res: Response) => ({
|
||||
role: String(res.locals.user?.role || '')
|
||||
})
|
||||
|
||||
const DISPOSAL_STATUSES = ['pending', 'approved', 'rejected', 'completed'] as const
|
||||
|
||||
const parseListPositiveInteger = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
fallback: number,
|
||||
max?: number
|
||||
) => {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
|
||||
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const withHistoricalDisposalNotes = <T extends {
|
||||
status: string
|
||||
notes?: string | null
|
||||
@ -388,7 +404,12 @@ export const createDisposal = async (req: Request, res: Response) => {
|
||||
export const getDisposalList = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10, status, keyword, equipment_id, scope } = req.query
|
||||
const currentPage = parseListPositiveInteger(page, '页码', 1)
|
||||
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
|
||||
const currentUser = getCurrentUser(res)
|
||||
if (!Number.isInteger(currentUser.id) || currentUser.id <= 0) {
|
||||
return errorResponse(res, '用户未登录', 401)
|
||||
}
|
||||
const resolvedScope = String(scope || (currentUser.role === 'admin'
|
||||
? 'all'
|
||||
: currentUser.role === 'administrative'
|
||||
@ -399,50 +420,61 @@ export const getDisposalList = async (req: Request, res: Response) => {
|
||||
return errorResponse(res, '报废列表范围无效', 422)
|
||||
}
|
||||
|
||||
const scopeConditions: Prisma.DisposalRecordWhereInput[] = []
|
||||
const summaryConditions: Prisma.DisposalRecordWhereInput[] = []
|
||||
|
||||
if (currentUser.role === 'it') {
|
||||
scopeConditions.push({ applied_by: currentUser.id })
|
||||
summaryConditions.push({ applied_by: currentUser.id })
|
||||
} else if (currentUser.role === 'administrative') {
|
||||
if (resolvedScope === 'pending') {
|
||||
scopeConditions.push({ status: 'pending' })
|
||||
summaryConditions.push({ status: 'pending' })
|
||||
} else if (resolvedScope === 'processed') {
|
||||
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
|
||||
summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
|
||||
} else {
|
||||
return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403)
|
||||
}
|
||||
} else if (currentUser.role === 'admin') {
|
||||
if (resolvedScope === 'mine') {
|
||||
scopeConditions.push({ applied_by: currentUser.id })
|
||||
summaryConditions.push({ applied_by: currentUser.id })
|
||||
} else if (resolvedScope === 'pending') {
|
||||
scopeConditions.push({ status: 'pending' })
|
||||
summaryConditions.push({ status: 'pending' })
|
||||
} else if (resolvedScope === 'processed') {
|
||||
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
|
||||
summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
|
||||
}
|
||||
} else {
|
||||
return errorResponse(res, '无权查看报废流程', 403)
|
||||
}
|
||||
|
||||
if (status) scopeConditions.push({ status: String(status) })
|
||||
if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) })
|
||||
if (keyword) {
|
||||
const keywordWhere = [
|
||||
{ equipment_code: { contains: String(keyword) } },
|
||||
{ disposal_reason: { contains: String(keyword) } },
|
||||
{ items: { some: { equipment_code: { contains: String(keyword) } } } }
|
||||
]
|
||||
scopeConditions.push({ OR: keywordWhere })
|
||||
const normalizedStatus = String(status || '').trim()
|
||||
if (normalizedStatus && !DISPOSAL_STATUSES.includes(normalizedStatus as any)) {
|
||||
return errorResponse(res, '报废状态无效', 422)
|
||||
}
|
||||
const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0
|
||||
? { AND: scopeConditions }
|
||||
if (equipment_id !== undefined && equipment_id !== '') {
|
||||
summaryConditions.push({
|
||||
equipment_id: parseListPositiveInteger(equipment_id, '资产ID', 0)
|
||||
})
|
||||
}
|
||||
const normalizedKeyword = String(keyword || '').trim()
|
||||
if (normalizedKeyword) {
|
||||
const keywordWhere = [
|
||||
{ equipment_code: { contains: normalizedKeyword } },
|
||||
{ disposal_reason: { contains: normalizedKeyword } },
|
||||
{ items: { some: { equipment_code: { contains: normalizedKeyword } } } }
|
||||
]
|
||||
summaryConditions.push({ OR: keywordWhere })
|
||||
}
|
||||
const summaryWhere: Prisma.DisposalRecordWhereInput = summaryConditions.length > 0
|
||||
? { AND: summaryConditions }
|
||||
: {}
|
||||
const where: Prisma.DisposalRecordWhereInput = normalizedStatus
|
||||
? { AND: [summaryWhere, { status: normalizedStatus }] }
|
||||
: summaryWhere
|
||||
|
||||
const skip = (Number(page) - 1) * Number(pageSize)
|
||||
const [list, total] = await Promise.all([
|
||||
const skip = (currentPage - 1) * currentPageSize
|
||||
const [list, total, statusGroups] = await Promise.all([
|
||||
prisma.disposalRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(pageSize),
|
||||
take: currentPageSize,
|
||||
orderBy: { created_at: 'desc' },
|
||||
include: {
|
||||
equipment: { select: { equipment_name: true, equipment_type: true, unit_price: true } },
|
||||
@ -452,15 +484,40 @@ export const getDisposalList = async (req: Request, res: Response) => {
|
||||
_count: { select: { items: true } }
|
||||
}
|
||||
}),
|
||||
prisma.disposalRecord.count({ where })
|
||||
prisma.disposalRecord.count({ where }),
|
||||
prisma.disposalRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: summaryWhere,
|
||||
_count: { _all: true }
|
||||
})
|
||||
])
|
||||
const summary = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
completed: 0
|
||||
}
|
||||
for (const group of statusGroups) {
|
||||
const count = group._count._all
|
||||
summary.total += count
|
||||
if (DISPOSAL_STATUSES.includes(group.status as any)) {
|
||||
summary[group.status as (typeof DISPOSAL_STATUSES)[number]] = count
|
||||
}
|
||||
}
|
||||
successResponse(
|
||||
res,
|
||||
{ list: list.map(withHistoricalDisposalNotes), total, page: Number(page), pageSize: Number(pageSize) },
|
||||
{
|
||||
list: list.map(withHistoricalDisposalNotes),
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize,
|
||||
summary
|
||||
},
|
||||
'获取成功'
|
||||
)
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '获取报废记录列表失败')
|
||||
errorResponse(res, error.message || '获取报废记录列表失败', error.status || 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,21 @@ class MaintenanceConflictError extends Error {
|
||||
}
|
||||
|
||||
const blockedMaintenanceStatuses = new Set(['已借出', '维护中', '待报废', '已报废'])
|
||||
const MAINTENANCE_STATUSES = ['in_progress', 'completed', 'cancelled'] as const
|
||||
|
||||
const parseListPositiveInteger = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
fallback: number,
|
||||
max?: number
|
||||
) => {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
|
||||
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// 创建维修记录
|
||||
export const createMaintenance = async (req: Request, res: Response) => {
|
||||
@ -133,36 +148,74 @@ export const createMaintenance = async (req: Request, res: Response) => {
|
||||
export const getMaintenanceList = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query
|
||||
const skip = (Number(page) - 1) * Number(pageSize)
|
||||
const currentUserRole = String(res.locals.user?.role || '')
|
||||
if (!['admin', 'it'].includes(currentUserRole)) {
|
||||
return errorResponse(res, '无权查看维修记录', 403)
|
||||
}
|
||||
const currentPage = parseListPositiveInteger(page, '页码', 1)
|
||||
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
|
||||
const skip = (currentPage - 1) * currentPageSize
|
||||
|
||||
const where: any = {}
|
||||
if (status) where.status = status
|
||||
if (equipment_id) where.equipment_id = Number(equipment_id)
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ equipment_code: { contains: String(keyword) } },
|
||||
{ issue_description: { contains: String(keyword) } },
|
||||
{ repair_vendor: { contains: String(keyword) } }
|
||||
const normalizedStatus = String(status || '').trim()
|
||||
if (normalizedStatus && !MAINTENANCE_STATUSES.includes(normalizedStatus as any)) {
|
||||
return errorResponse(res, '维修状态无效', 422)
|
||||
}
|
||||
const summaryWhere: any = {}
|
||||
if (equipment_id !== undefined && equipment_id !== '') {
|
||||
summaryWhere.equipment_id = parseListPositiveInteger(equipment_id, '资产ID', 0)
|
||||
}
|
||||
const normalizedKeyword = String(keyword || '').trim()
|
||||
if (normalizedKeyword) {
|
||||
summaryWhere.OR = [
|
||||
{ equipment_code: { contains: normalizedKeyword } },
|
||||
{ issue_description: { contains: normalizedKeyword } },
|
||||
{ repair_vendor: { contains: normalizedKeyword } }
|
||||
]
|
||||
}
|
||||
const where = normalizedStatus
|
||||
? { AND: [summaryWhere, { status: normalizedStatus }] }
|
||||
: summaryWhere
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
const [list, total, statusGroups] = await Promise.all([
|
||||
prisma.maintenanceRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(pageSize),
|
||||
take: currentPageSize,
|
||||
orderBy: { created_at: 'desc' },
|
||||
include: {
|
||||
equipment: { select: { equipment_name: true, equipment_type: true } },
|
||||
creator: { select: { id: true, name: true, username: true } }
|
||||
}
|
||||
}),
|
||||
prisma.maintenanceRecord.count({ where })
|
||||
prisma.maintenanceRecord.count({ where }),
|
||||
prisma.maintenanceRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: summaryWhere,
|
||||
_count: { _all: true }
|
||||
})
|
||||
])
|
||||
|
||||
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功')
|
||||
const summary = {
|
||||
total: 0,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
cancelled: 0
|
||||
}
|
||||
for (const group of statusGroups) {
|
||||
const count = group._count._all
|
||||
summary.total += count
|
||||
if (MAINTENANCE_STATUSES.includes(group.status as any)) {
|
||||
summary[group.status as (typeof MAINTENANCE_STATUSES)[number]] = count
|
||||
}
|
||||
}
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{ list, total, page: currentPage, pageSize: currentPageSize, summary },
|
||||
'获取成功'
|
||||
)
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '获取维修记录列表失败')
|
||||
errorResponse(res, error.message || '获取维修记录列表失败', error.status || 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,15 +2,121 @@ import type { Request, Response } from 'express'
|
||||
import prisma from '../utils/prisma'
|
||||
import { errorResponse, successResponse } from '../utils/response'
|
||||
|
||||
const parseChildrenSnapshot = (value: string | null) => {
|
||||
if (!value) return []
|
||||
const parseChildrenSnapshot = (value: string | null, rootEquipmentId: number) => {
|
||||
if (!value) return { items: [] as any[], valid: true }
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
if (!Array.isArray(parsed)) return { items: [] as any[], valid: false }
|
||||
|
||||
const depthById = new Map<number, number>([[rootEquipmentId, 0]])
|
||||
const items = parsed
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item: Record<string, unknown>) => {
|
||||
const id = Number(item.id)
|
||||
const rawParentId = Number(item.parent_id ?? item.parent_equipment_id)
|
||||
const parentId = Number.isInteger(rawParentId) && rawParentId > 0
|
||||
? rawParentId
|
||||
: rootEquipmentId
|
||||
const rawDepth = Number(item.depth)
|
||||
const parentDepth = depthById.get(parentId)
|
||||
const depth = Number.isInteger(rawDepth) && rawDepth > 0
|
||||
? rawDepth
|
||||
: (parentDepth ?? 0) + 1
|
||||
if (Number.isInteger(id) && id > 0) depthById.set(id, depth)
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
parent_id: parentId,
|
||||
parent_equipment_id: parentId,
|
||||
root_equipment_id: rootEquipmentId,
|
||||
depth,
|
||||
relation_source: 'borrow_snapshot' as const
|
||||
}
|
||||
})
|
||||
.filter((item) => Number.isInteger(item.id) && item.id > 0)
|
||||
|
||||
return { items, valid: true }
|
||||
} catch {
|
||||
return []
|
||||
return { items: [] as any[], valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
const currentAccessorySelect = {
|
||||
id: true,
|
||||
equipment_code: true,
|
||||
equipment_name: true,
|
||||
equipment_model: true,
|
||||
equipment_type: true,
|
||||
status: true,
|
||||
parent_id: true,
|
||||
mount_status: true,
|
||||
department_id: true,
|
||||
storage_location_id: true
|
||||
} as const
|
||||
|
||||
const loadCurrentAccessories = async (rootEquipmentIds: number[]) => {
|
||||
const uniqueRootIds = [...new Set(rootEquipmentIds)]
|
||||
const result = new Map<number, any[]>(uniqueRootIds.map((id) => [id, []]))
|
||||
const nodeMeta = new Map<number, { rootEquipmentId: number; depth: number }>(
|
||||
uniqueRootIds.map((id) => [id, { rootEquipmentId: id, depth: 0 }])
|
||||
)
|
||||
const visited = new Set(uniqueRootIds)
|
||||
let parentIds = uniqueRootIds
|
||||
let depth = 0
|
||||
|
||||
while (parentIds.length > 0) {
|
||||
if (depth >= 100) {
|
||||
throw Object.assign(new Error('资产父子关系超过 100 层,请先检查资产关系'), { status: 409 })
|
||||
}
|
||||
const children = await prisma.equipment.findMany({
|
||||
where: { parent_id: { in: parentIds } },
|
||||
select: currentAccessorySelect,
|
||||
orderBy: { id: 'asc' }
|
||||
})
|
||||
if (children.length === 0) break
|
||||
|
||||
for (const child of children) {
|
||||
if (visited.has(child.id)) {
|
||||
throw Object.assign(
|
||||
new Error(`资产 ${child.equipment_code} 存在循环或重复父子关系,请先检查资产关系`),
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
const parentMeta = child.parent_id ? nodeMeta.get(child.parent_id) : null
|
||||
if (!parentMeta) {
|
||||
throw Object.assign(
|
||||
new Error(`资产 ${child.equipment_code} 的父子关系不完整,请先检查资产关系`),
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
const item = {
|
||||
...child,
|
||||
parent_equipment_id: child.parent_id,
|
||||
root_equipment_id: parentMeta.rootEquipmentId,
|
||||
depth: parentMeta.depth + 1,
|
||||
relation_source: 'current' as const
|
||||
}
|
||||
visited.add(child.id)
|
||||
nodeMeta.set(child.id, {
|
||||
rootEquipmentId: parentMeta.rootEquipmentId,
|
||||
depth: parentMeta.depth + 1
|
||||
})
|
||||
result.get(parentMeta.rootEquipmentId)?.push(item)
|
||||
}
|
||||
|
||||
parentIds = children.map((child) => child.id)
|
||||
depth += 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const getRelationshipFingerprint = (items: Array<{ id: number; parent_id?: number | null }>) =>
|
||||
items
|
||||
.map((item) => `${item.id}:${item.parent_id ?? 'null'}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
export const getMyAssets = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const personId = Number(res.locals.user?.person_id)
|
||||
@ -45,38 +151,44 @@ export const getMyAssets = async (_req: Request, res: Response) => {
|
||||
include: {
|
||||
storageLocation: { select: { id: true, name: true, code: true } },
|
||||
department: { select: { id: true, name: true } },
|
||||
children: {
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
equipment_code: true,
|
||||
equipment_name: true,
|
||||
equipment_model: true,
|
||||
equipment_type: true,
|
||||
status: true,
|
||||
parent_id: true
|
||||
}
|
||||
}
|
||||
children: { orderBy: { id: 'asc' }, select: currentAccessorySelect }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const currentAccessoriesByRoot = await loadCurrentAccessories(
|
||||
records.map((record) => record.equipment_id)
|
||||
)
|
||||
|
||||
successResponse(res, {
|
||||
person,
|
||||
assets: records.map((record) => ({
|
||||
borrow_record_id: record.id,
|
||||
borrow_time: record.borrow_time,
|
||||
expected_return_time: record.expected_return_time,
|
||||
is_long_term: record.is_long_term,
|
||||
notes: record.notes,
|
||||
equipment: record.equipment,
|
||||
children: record.equipment.children.length > 0
|
||||
? record.equipment.children
|
||||
: parseChildrenSnapshot(record.children_snapshot)
|
||||
}))
|
||||
assets: records.map((record) => {
|
||||
const currentAccessories = currentAccessoriesByRoot.get(record.equipment_id) || []
|
||||
const snapshot = parseChildrenSnapshot(record.children_snapshot, record.equipment_id)
|
||||
const relationshipChanged = !snapshot.valid ||
|
||||
getRelationshipFingerprint(currentAccessories) !== getRelationshipFingerprint(snapshot.items)
|
||||
const currentDirectChildren = currentAccessories.filter((item) => item.depth === 1)
|
||||
const legacyChildren = currentDirectChildren.length > 0
|
||||
? currentDirectChildren
|
||||
: snapshot.items
|
||||
|
||||
return {
|
||||
borrow_record_id: record.id,
|
||||
borrow_time: record.borrow_time,
|
||||
expected_return_time: record.expected_return_time,
|
||||
is_long_term: record.is_long_term,
|
||||
notes: record.notes,
|
||||
equipment: { ...record.equipment, children: currentDirectChildren },
|
||||
children: legacyChildren,
|
||||
current_accessories: currentAccessories,
|
||||
borrowed_accessories_snapshot: snapshot.items,
|
||||
borrowed_accessories_snapshot_valid: snapshot.valid,
|
||||
accessory_relationship_changed: relationshipChanged
|
||||
}
|
||||
})
|
||||
}, '获取成功')
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '获取本人资产失败')
|
||||
errorResponse(res, error.message || '获取本人资产失败', error.status || 500)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { getPermissionsForRole, isUserRole } from '../utils/permissions'
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
const USER_STATUSES = ['active', 'inactive'] as const
|
||||
const PERSON_BOUND_FILTERS = ['all', 'bound', 'unbound'] as const
|
||||
|
||||
class UserValidationError extends Error {
|
||||
constructor(message: string, public readonly status = 422) {
|
||||
@ -83,6 +84,28 @@ const getCurrentUserId = (res: Response) => {
|
||||
return Number.isInteger(userId) && userId > 0 ? userId : null
|
||||
}
|
||||
|
||||
const parseListPositiveInteger = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
fallback: number,
|
||||
max?: number
|
||||
) => {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
|
||||
throw new UserValidationError(`${fieldName}无效`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const parseOptionalBoolean = (value: unknown, fieldName: string) => {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const normalized = String(value).trim().toLowerCase()
|
||||
if (normalized === 'true') return true
|
||||
if (normalized === 'false') return false
|
||||
throw new UserValidationError(`${fieldName}必须是 true 或 false`)
|
||||
}
|
||||
|
||||
const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => {
|
||||
return tx.$queryRawUnsafe<Array<{ id: number }>>(
|
||||
"SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE"
|
||||
@ -141,33 +164,47 @@ const sendUserWriteError = (res: Response, error: any, fallbackMessage: string)
|
||||
|
||||
export const getUserList = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10, keyword, role, status } = req.query
|
||||
const { page = 1, pageSize = 10, keyword, role, roles, status } = req.query
|
||||
const currentPage = parseListPositiveInteger(page, '页码', 1)
|
||||
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
|
||||
|
||||
const where: any = {}
|
||||
const where: Prisma.UserWhereInput = {}
|
||||
|
||||
if (keyword) {
|
||||
const normalizedKeyword = String(keyword || '').trim()
|
||||
if (normalizedKeyword) {
|
||||
where.OR = [
|
||||
{ username: { contains: keyword as string } },
|
||||
{ name: { contains: keyword as string } },
|
||||
{ email: { contains: keyword as string } }
|
||||
{ username: { contains: normalizedKeyword } },
|
||||
{ name: { contains: normalizedKeyword } },
|
||||
{ email: { contains: normalizedKeyword } },
|
||||
{ person: { is: { real_name: { contains: normalizedKeyword } } } },
|
||||
{ person: { is: { nickname: { contains: normalizedKeyword } } } },
|
||||
{ person: { is: { job_number: { contains: normalizedKeyword } } } }
|
||||
]
|
||||
}
|
||||
|
||||
if (role) {
|
||||
where.role = role
|
||||
const normalizedRole = String(role || (Array.isArray(roles) ? roles[0] : roles) || '').trim()
|
||||
if (normalizedRole) {
|
||||
if (!isUserRole(normalizedRole)) {
|
||||
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
|
||||
}
|
||||
where.role = normalizedRole
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status
|
||||
const normalizedStatus = String(status || '').trim()
|
||||
if (normalizedStatus) {
|
||||
if (!USER_STATUSES.includes(normalizedStatus as (typeof USER_STATUSES)[number])) {
|
||||
return errorResponse(res, '状态必须是 active 或 inactive', 422)
|
||||
}
|
||||
where.status = normalizedStatus
|
||||
}
|
||||
|
||||
const skip = (Number(page) - 1) * Number(pageSize)
|
||||
const skip = (currentPage - 1) * currentPageSize
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(pageSize),
|
||||
take: currentPageSize,
|
||||
orderBy: { created_at: 'desc' },
|
||||
include: { person: { select: personSelect } }
|
||||
}),
|
||||
@ -179,13 +216,109 @@ export const getUserList = async (req: Request, res: Response) => {
|
||||
{
|
||||
list: list.map(sanitizeUser),
|
||||
total,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize)
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize
|
||||
},
|
||||
'获取成功'
|
||||
)
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '获取用户列表失败')
|
||||
errorResponse(res, error.message || '获取用户列表失败', error.status || 500)
|
||||
}
|
||||
}
|
||||
|
||||
export const getPersonBindingOptions = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, keyword, status, bound = 'all', dingtalk_ready } = req.query
|
||||
const currentPage = parseListPositiveInteger(page, '页码', 1)
|
||||
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 20, 100)
|
||||
const normalizedBound = String(bound || 'all').trim().toLowerCase()
|
||||
if (!PERSON_BOUND_FILTERS.includes(normalizedBound as (typeof PERSON_BOUND_FILTERS)[number])) {
|
||||
return errorResponse(res, '绑定状态必须是 all、bound 或 unbound', 422)
|
||||
}
|
||||
const normalizedStatus = String(status || '').trim()
|
||||
if (normalizedStatus && !USER_STATUSES.includes(normalizedStatus as (typeof USER_STATUSES)[number])) {
|
||||
return errorResponse(res, '员工状态必须是 active 或 inactive', 422)
|
||||
}
|
||||
const dingtalkReady = parseOptionalBoolean(dingtalk_ready, 'dingtalk_ready')
|
||||
|
||||
const conditions: Prisma.PersonWhereInput[] = []
|
||||
const normalizedKeyword = String(keyword || '').trim()
|
||||
if (normalizedKeyword) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ real_name: { contains: normalizedKeyword } },
|
||||
{ nickname: { contains: normalizedKeyword } },
|
||||
{ job_number: { contains: normalizedKeyword } },
|
||||
{ mainDepartment: { is: { name: { contains: normalizedKeyword } } } }
|
||||
]
|
||||
})
|
||||
}
|
||||
if (normalizedStatus) conditions.push({ status: normalizedStatus })
|
||||
if (normalizedBound === 'bound') conditions.push({ user: { isNot: null } })
|
||||
if (normalizedBound === 'unbound') conditions.push({ user: { is: null } })
|
||||
if (dingtalkReady === true) {
|
||||
conditions.push({
|
||||
status: 'active',
|
||||
AND: [{ source_userid: { not: null } }, { source_userid: { not: '' } }]
|
||||
})
|
||||
} else if (dingtalkReady === false) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ status: { not: 'active' } },
|
||||
{ source_userid: null },
|
||||
{ source_userid: '' }
|
||||
]
|
||||
})
|
||||
}
|
||||
const where: Prisma.PersonWhereInput = conditions.length > 0 ? { AND: conditions } : {}
|
||||
const skip = (currentPage - 1) * currentPageSize
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
prisma.person.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: currentPageSize,
|
||||
orderBy: [{ status: 'asc' }, { nickname: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
real_name: true,
|
||||
nickname: true,
|
||||
job_number: true,
|
||||
status: true,
|
||||
source_userid: true,
|
||||
mainDepartment: { select: { id: true, name: true } },
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
role: true,
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.person.count({ where })
|
||||
])
|
||||
|
||||
successResponse(res, {
|
||||
list: list.map((person) => ({
|
||||
id: person.id,
|
||||
real_name: person.real_name,
|
||||
nickname: person.nickname,
|
||||
job_number: person.job_number,
|
||||
status: person.status,
|
||||
main_department: person.mainDepartment,
|
||||
dingtalk_ready: person.status === 'active' && Boolean(person.source_userid?.trim()),
|
||||
is_bound: Boolean(person.user),
|
||||
bound_user: person.user
|
||||
})),
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize
|
||||
}, '获取成功')
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '获取员工绑定选项失败', error.status || 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ import printAgentRoutes from './routes/printAgent'
|
||||
import printDownloadRoutes from './routes/printDownloads'
|
||||
import myAssetRoutes from './routes/myAssets'
|
||||
import dingtalkRoutes from './routes/dingtalk'
|
||||
import { keycloak, memoryStore } from './utils/keycloak'
|
||||
import { keycloak } from './utils/keycloak'
|
||||
import { protect } from './middleware/auth'
|
||||
import { startHrSyncScheduler } from './services/hrSyncService'
|
||||
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
|
||||
@ -40,6 +40,7 @@ import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService'
|
||||
import { isCorsOriginAllowed } from './utils/corsPolicy'
|
||||
import { unsafeOriginGuard } from './middleware/originGuard'
|
||||
import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode'
|
||||
import { persistentSessionStore, startSessionCleanupWorker } from './utils/sessionStore'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
@ -63,7 +64,7 @@ app.use(
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: memoryStore,
|
||||
store: persistentSessionStore,
|
||||
cookie: {
|
||||
secure: SESSION_COOKIE_SECURE,
|
||||
httpOnly: true,
|
||||
@ -129,6 +130,8 @@ app.listen(PORT, () => {
|
||||
console.log(`Server is running on http://localhost:${PORT}`)
|
||||
})
|
||||
|
||||
startSessionCleanupWorker()
|
||||
|
||||
ensurePublicAssetPersonExists().catch((error) => {
|
||||
console.error('初始化系统保留人员失败:', error)
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import {
|
||||
getUserList,
|
||||
getPersonBindingOptions,
|
||||
createUser,
|
||||
updateUser,
|
||||
resetPassword,
|
||||
@ -15,6 +16,7 @@ const router = Router()
|
||||
router.use(requirePermission(PERMISSIONS.USERS_MANAGE))
|
||||
|
||||
router.get('/', getUserList)
|
||||
router.get('/person-options', getPersonBindingOptions)
|
||||
router.post('/', createUser)
|
||||
router.put('/:id', updateUser)
|
||||
router.post('/:id/reset-password', resetPassword)
|
||||
|
||||
@ -141,7 +141,99 @@ test('站内收件人与钉钉收件人相互独立且动态字段会转义Markd
|
||||
actionTitle: '立即审批'
|
||||
}
|
||||
})
|
||||
assert.deepEqual(stationRows, [{ user_id: 11 }])
|
||||
assert.equal(stationRows.length, 1)
|
||||
assert.equal(stationRows[0].user_id, 11)
|
||||
assert.equal(stationRows[0].is_read, true)
|
||||
assert.ok(stationRows[0].read_at instanceof Date)
|
||||
assert.equal(deliveryCount, -1)
|
||||
assert.equal(escapeNotificationMarkdownInline('[资产](https://evil.example)'), '\\[资产\\]\\(https://evil\\.example\\)')
|
||||
})
|
||||
|
||||
test('全量模式没有有效钉钉收件人时生成管理员未读待处理记录', async () => {
|
||||
const notificationRows: any[] = []
|
||||
let deliveryCount = 0
|
||||
const client = {
|
||||
user: {
|
||||
findMany: async () => [{ id: 99 }]
|
||||
},
|
||||
notification: {
|
||||
create: async (args: any) => {
|
||||
notificationRows.push(args.data)
|
||||
return { id: 10 + notificationRows.length }
|
||||
}
|
||||
},
|
||||
dingTalkDelivery: {
|
||||
createMany: async (args: any) => {
|
||||
deliveryCount += args.data.length
|
||||
return { count: args.data.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await createBusinessNotification(client as any, {
|
||||
eventType: 'hr_sync_failed',
|
||||
businessType: 'hr_sync',
|
||||
businessId: 81,
|
||||
title: '花名册同步失败',
|
||||
content: '请管理员处理',
|
||||
actionUrl: '/notifications',
|
||||
stationRecipients: [{ id: 3 }],
|
||||
dingTalkRecipients: [],
|
||||
rollout: { enabled: true, fullRollout: true, testUserIds: [] },
|
||||
dingTalk: {
|
||||
title: '花名册同步失败',
|
||||
content: '正文',
|
||||
actionTitle: '查看同步结果'
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(deliveryCount, 0)
|
||||
assert.equal(notificationRows.length, 2)
|
||||
assert.equal(notificationRows[0].title, '花名册同步失败')
|
||||
assert.equal(notificationRows[0].recipients.create[0].is_read, true)
|
||||
assert.ok(notificationRows[0].recipients.create[0].read_at instanceof Date)
|
||||
assert.match(notificationRows[1].title, /^钉钉未发送:/)
|
||||
assert.equal(notificationRows[1].level, 'error')
|
||||
assert.deepEqual(notificationRows[1].recipients.create, [{ user_id: 99 }])
|
||||
const metadata = JSON.parse(notificationRows[1].metadata)
|
||||
assert.equal(metadata.record_state, 'attention')
|
||||
assert.equal(metadata.attention_reason, 'NO_VALID_DINGTALK_RECIPIENT')
|
||||
})
|
||||
|
||||
test('测试灰度名单外没有收件人时只保存已读系统记录且不积压任务', async () => {
|
||||
let notificationData: any
|
||||
let deliveryCount = 0
|
||||
const client = {
|
||||
notification: {
|
||||
create: async (args: any) => {
|
||||
notificationData = args.data
|
||||
return { id: 11 }
|
||||
}
|
||||
},
|
||||
dingTalkDelivery: {
|
||||
createMany: async (args: any) => {
|
||||
deliveryCount += args.data.length
|
||||
return { count: args.data.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await createBusinessNotification(client as any, {
|
||||
eventType: 'disposal_applied',
|
||||
businessType: 'disposal',
|
||||
businessId: 82,
|
||||
title: '报废申请',
|
||||
content: '测试灰度外人员',
|
||||
actionUrl: '/workflows/disposals/82',
|
||||
stationRecipients: [{ id: 4 }],
|
||||
dingTalkRecipients: [],
|
||||
rollout: { enabled: true, fullRollout: false, testUserIds: ['ding-test-user'] },
|
||||
dingTalk: { title: '报废申请', content: '正文', actionTitle: '查看' }
|
||||
})
|
||||
|
||||
assert.equal(deliveryCount, 0)
|
||||
assert.equal(notificationData.title, '报废申请')
|
||||
assert.equal(notificationData.recipients.create[0].is_read, true)
|
||||
assert.ok(notificationData.recipients.create[0].read_at instanceof Date)
|
||||
assert.equal(JSON.parse(notificationData.metadata).record_state, 'record')
|
||||
})
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import prisma from '../../utils/prisma'
|
||||
|
||||
import {
|
||||
buildClaimableDeliveryWhere,
|
||||
buildDeliveryFailureAttention,
|
||||
buildRefreshableDeliveryWhere,
|
||||
buildRefreshErrorUpdate,
|
||||
buildUnreportedFailureWhere,
|
||||
ensureDeliveryFailureEscalation,
|
||||
isDeliveryRecipientStillEligible,
|
||||
resolveDeliveryActionTitle,
|
||||
resolveMonotonicNotificationCenterStatus,
|
||||
@ -78,3 +82,80 @@ test('投递前按当前账号、审批角色、员工状态和userid精确复
|
||||
assert.equal(capturedWhere.person.is.status, 'active')
|
||||
assert.equal(capturedWhere.person.is.source_userid, 'ding-user-7')
|
||||
})
|
||||
|
||||
test('投递终态失败与状态回查异常使用不同的管理员提示', () => {
|
||||
const failed = buildDeliveryFailureAttention({
|
||||
recipientName: '魔礼红',
|
||||
payloadJson: JSON.stringify({ title: '报废审批' })
|
||||
})
|
||||
assert.equal(failed.reason, 'DINGTALK_DELIVERY_FAILED')
|
||||
assert.match(failed.content, /最终投递失败/)
|
||||
|
||||
const queryFailed = buildDeliveryFailureAttention({
|
||||
recipientName: '魔礼红',
|
||||
payloadJson: JSON.stringify({ notification_center_status_query_error: 'NETWORK' })
|
||||
})
|
||||
assert.equal(queryFailed.reason, 'DINGTALK_STATUS_QUERY_FAILED')
|
||||
assert.match(queryFailed.content, /请勿重复补发/)
|
||||
})
|
||||
|
||||
test('失败告警扫描只处理统一通知中心的新失败任务', () => {
|
||||
assert.deepEqual(buildUnreportedFailureWhere(), {
|
||||
provider_type: 'notification_center',
|
||||
status: 'failed',
|
||||
failure_notified_at: null
|
||||
})
|
||||
})
|
||||
|
||||
test('投递失败告警通过CAS只创建一次且忽略历史工作通知', async () => {
|
||||
const originalTransaction = prisma.$transaction
|
||||
let providerType = 'notification_center'
|
||||
let failureNotifiedAt: Date | null = null
|
||||
let notificationCount = 0
|
||||
const tx = {
|
||||
dingTalkDelivery: {
|
||||
findUnique: async () => ({
|
||||
id: 51,
|
||||
status: 'failed',
|
||||
provider_type: providerType,
|
||||
failure_notified_at: failureNotifiedAt,
|
||||
event_type: 'disposal_applied',
|
||||
business_type: 'disposal',
|
||||
business_id: 5,
|
||||
recipient_user_id: 7,
|
||||
last_error_code: 'TIMEOUT',
|
||||
last_error_message: '超时',
|
||||
payload_json: JSON.stringify({ title: '报废审批' }),
|
||||
recipient: { name: '魔礼红', username: 'admin' }
|
||||
}),
|
||||
updateMany: async () => {
|
||||
if (failureNotifiedAt) return { count: 0 }
|
||||
failureNotifiedAt = new Date()
|
||||
return { count: 1 }
|
||||
}
|
||||
},
|
||||
user: {
|
||||
findMany: async () => [{ id: 1 }]
|
||||
},
|
||||
notification: {
|
||||
create: async () => {
|
||||
notificationCount += 1
|
||||
return { id: notificationCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
;(prisma.$transaction as any) = async (callback: any) => callback(tx)
|
||||
|
||||
try {
|
||||
await ensureDeliveryFailureEscalation(51)
|
||||
await ensureDeliveryFailureEscalation(51)
|
||||
assert.equal(notificationCount, 1)
|
||||
|
||||
providerType = 'work_notice'
|
||||
failureNotifiedAt = null
|
||||
await ensureDeliveryFailureEscalation(52)
|
||||
assert.equal(notificationCount, 1)
|
||||
} finally {
|
||||
;(prisma.$transaction as any) = originalTransaction
|
||||
}
|
||||
})
|
||||
|
||||
@ -3,9 +3,11 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildHrSyncPreviewLine,
|
||||
buildHrSyncDingTalkContent,
|
||||
formatBorrowedAssetSummary,
|
||||
resolveHrSyncPersonDisplayName,
|
||||
resolveSourcePersonStatus,
|
||||
shouldNotifyHrSync,
|
||||
shouldUseSourceDepartmentBinding
|
||||
} from '../hrSyncHelpers'
|
||||
|
||||
@ -128,3 +130,22 @@ test('buildHrSyncPreviewLine 在没有结构化资产时仍兼容旧的详情文
|
||||
|
||||
assert.equal(line, '岗位变动:悟空(名下资产:JS-PC-2001 / 主机)')
|
||||
})
|
||||
|
||||
test('花名册同步无变化只保存日志,有变化或失败才通知管理员', () => {
|
||||
assert.equal(shouldNotifyHrSync('success', 0), false)
|
||||
assert.equal(shouldNotifyHrSync('success', 2), true)
|
||||
assert.equal(shouldNotifyHrSync('failed', 0), true)
|
||||
})
|
||||
|
||||
test('花名册钉钉汇总按时间、结果和待处理内容排版', () => {
|
||||
const content = buildHrSyncDingTalkContent({
|
||||
status: 'success',
|
||||
occurredAt: new Date('2026-07-16T02:00:00.000Z'),
|
||||
changesCount: 3,
|
||||
summary: '新增人员 2 项,部门变化 1 项。'
|
||||
})
|
||||
|
||||
assert.match(content, /\*\*同步时间:\*\* 2026年7月16日 10:00/)
|
||||
assert.match(content, /\*\*执行结果:\*\* 发现 3 项变化,需要管理员核对/)
|
||||
assert.match(content, /\*\*待处理内容:\*\* 新增人员 2 项,部门变化 1 项。/)
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { getDingTalkConfigStatus, getDingTalkSendRolloutStatus } from './dingtalkClient'
|
||||
import { getDingTalkSendRolloutStatus } from './dingtalkClient'
|
||||
|
||||
type TransactionClient = Prisma.TransactionClient
|
||||
|
||||
@ -28,10 +28,11 @@ export type DingTalkRecipientRollout = {
|
||||
}
|
||||
|
||||
export const getCurrentDingTalkRecipientRollout = (): DingTalkRecipientRollout => {
|
||||
const config = getDingTalkConfigStatus()
|
||||
const rollout = getDingTalkSendRolloutStatus()
|
||||
return {
|
||||
enabled: config.send_enabled,
|
||||
// rollout 表示业务是否要求发送;配置完整性由 Worker/客户端单独处理。
|
||||
// 这样全量模式临时缺配置时仍会留下可审计的投递任务,而不是静默丢消息。
|
||||
enabled: rollout.enabled,
|
||||
fullRollout: rollout.fullRollout,
|
||||
testUserIds: rollout.testUserIds
|
||||
}
|
||||
@ -154,6 +155,7 @@ export const createBusinessNotification = async (
|
||||
content: string
|
||||
level?: string
|
||||
actionUrl: string
|
||||
relatedSyncJobId?: number | null
|
||||
metadata?: Record<string, unknown>
|
||||
stationRecipients: StationNotificationRecipient[]
|
||||
dingTalkRecipients: NotificationRecipient[]
|
||||
@ -163,11 +165,21 @@ export const createBusinessNotification = async (
|
||||
actionTitle: string
|
||||
messageVersion?: string
|
||||
}
|
||||
rollout?: DingTalkRecipientRollout
|
||||
}
|
||||
) => {
|
||||
const stationRecipients = mergeRecipients(input.stationRecipients)
|
||||
const dingTalkRecipients = mergeRecipients(input.dingTalkRecipients)
|
||||
if (stationRecipients.length === 0 && dingTalkRecipients.length === 0) return null
|
||||
const rollout = input.rollout || getCurrentDingTalkRecipientRollout()
|
||||
const missingRecipientAttention = rollout.enabled && rollout.fullRollout && dingTalkRecipients.length === 0
|
||||
const attentionRecipients = missingRecipientAttention
|
||||
? await findActiveStationUsersByRoles(client, ['admin'])
|
||||
: []
|
||||
if (
|
||||
stationRecipients.length === 0 &&
|
||||
dingTalkRecipients.length === 0 &&
|
||||
attentionRecipients.length === 0
|
||||
) return null
|
||||
|
||||
const messageVersion = compactNotificationText(input.dingTalk.messageVersion, 'v1', 30)
|
||||
const deliveries = dingTalkRecipients.map((recipient) => {
|
||||
@ -202,14 +214,49 @@ export const createBusinessNotification = async (
|
||||
type: input.businessType,
|
||||
level: input.level || 'info',
|
||||
action_url: input.actionUrl,
|
||||
related_sync_job_id: input.relatedSyncJobId ?? null,
|
||||
metadata: JSON.stringify({
|
||||
event_type: input.eventType,
|
||||
business_type: input.businessType,
|
||||
business_id: input.businessId,
|
||||
record_state: 'record',
|
||||
attention_reason: null,
|
||||
delivery_count: deliveries.length,
|
||||
...(input.metadata || {})
|
||||
}),
|
||||
recipients: {
|
||||
create: stationRecipients.map((recipient) => ({ user_id: recipient.id }))
|
||||
create: stationRecipients.map((recipient) => ({
|
||||
user_id: recipient.id,
|
||||
is_read: true,
|
||||
read_at: new Date()
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
const attentionNotification = attentionRecipients.length > 0
|
||||
? await client.notification.create({
|
||||
data: {
|
||||
title: `钉钉未发送:${input.title}`,
|
||||
content: `${input.content}\n\n系统未找到有效的钉钉收件人,请管理员检查角色、员工绑定和在职状态。`,
|
||||
type: 'dingtalk_delivery',
|
||||
level: 'error',
|
||||
action_url: '/settings?tab=users',
|
||||
// 异常记录点击后应进入人员绑定处理页,不能被同步详情弹窗截获。
|
||||
related_sync_job_id: null,
|
||||
metadata: JSON.stringify({
|
||||
event_type: input.eventType,
|
||||
business_type: input.businessType,
|
||||
business_id: input.businessId,
|
||||
record_state: 'attention',
|
||||
attention_reason: 'NO_VALID_DINGTALK_RECIPIENT',
|
||||
delivery_count: deliveries.length
|
||||
}),
|
||||
recipients: {
|
||||
create: attentionRecipients.map((recipient) => ({
|
||||
user_id: recipient.id
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -222,5 +269,5 @@ export const createBusinessNotification = async (
|
||||
})
|
||||
}
|
||||
|
||||
return notification
|
||||
return notification || attentionNotification
|
||||
}
|
||||
|
||||
@ -143,6 +143,131 @@ const errorDetails = (error: unknown) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureDeliveryFailureEscalation = async (deliveryId: number) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.dingTalkDelivery.findUnique({
|
||||
where: { id: deliveryId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
provider_type: true,
|
||||
failure_notified_at: true,
|
||||
event_type: true,
|
||||
business_type: true,
|
||||
business_id: true,
|
||||
recipient_user_id: true,
|
||||
last_error_code: true,
|
||||
last_error_message: true,
|
||||
payload_json: true,
|
||||
recipient: { select: { name: true, username: true } }
|
||||
}
|
||||
})
|
||||
if (
|
||||
!delivery ||
|
||||
delivery.provider_type !== 'notification_center' ||
|
||||
delivery.status !== 'failed' ||
|
||||
delivery.failure_notified_at
|
||||
) return
|
||||
|
||||
const administrators = await tx.user.findMany({
|
||||
where: { role: 'admin', status: 'active' },
|
||||
select: { id: true }
|
||||
})
|
||||
if (administrators.length === 0) return
|
||||
|
||||
const escalatedAt = new Date()
|
||||
const claimed = await tx.dingTalkDelivery.updateMany({
|
||||
where: {
|
||||
id: delivery.id,
|
||||
provider_type: 'notification_center',
|
||||
status: 'failed',
|
||||
failure_notified_at: null
|
||||
},
|
||||
data: { failure_notified_at: escalatedAt }
|
||||
})
|
||||
if (claimed.count !== 1) return
|
||||
|
||||
const recipientName = delivery.recipient?.name || delivery.recipient?.username || `用户 #${delivery.recipient_user_id}`
|
||||
const attention = buildDeliveryFailureAttention({
|
||||
recipientName,
|
||||
payloadJson: delivery.payload_json
|
||||
})
|
||||
await tx.notification.create({
|
||||
data: {
|
||||
title: attention.title,
|
||||
content: attention.content,
|
||||
type: 'dingtalk_delivery',
|
||||
level: 'error',
|
||||
action_url: '/settings?tab=dingtalk',
|
||||
metadata: JSON.stringify({
|
||||
record_state: 'attention',
|
||||
attention_reason: attention.reason,
|
||||
delivery_id: delivery.id,
|
||||
event_type: delivery.event_type,
|
||||
business_type: delivery.business_type,
|
||||
business_id: delivery.business_id,
|
||||
recipient_user_id: delivery.recipient_user_id,
|
||||
error_code: delivery.last_error_code,
|
||||
error_message: delivery.last_error_message
|
||||
}),
|
||||
recipients: {
|
||||
create: administrators.map((administrator) => ({ user_id: administrator.id }))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const reportDeliveryFailure = async (deliveryId: number) => {
|
||||
try {
|
||||
await ensureDeliveryFailureEscalation(deliveryId)
|
||||
} catch (error) {
|
||||
console.error(`[NotificationCenterWorker] 创建投递失败待处理记录失败 delivery=${deliveryId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
export const buildUnreportedFailureWhere = (): Prisma.DingTalkDeliveryWhereInput => ({
|
||||
provider_type: 'notification_center',
|
||||
status: 'failed',
|
||||
failure_notified_at: null
|
||||
})
|
||||
|
||||
const escalateNextUnreportedFailure = async () => {
|
||||
const delivery = await prisma.dingTalkDelivery.findFirst({
|
||||
where: buildUnreportedFailureWhere(),
|
||||
orderBy: { updated_at: 'asc' },
|
||||
select: { id: true }
|
||||
})
|
||||
if (delivery) await reportDeliveryFailure(delivery.id)
|
||||
}
|
||||
|
||||
export const buildDeliveryFailureAttention = (input: {
|
||||
recipientName: string
|
||||
payloadJson: string
|
||||
}) => {
|
||||
let statusQueryFailed = false
|
||||
try {
|
||||
const payload = JSON.parse(input.payloadJson) as Record<string, unknown>
|
||||
statusQueryFailed = Boolean(payload.notification_center_status_query_error)
|
||||
} catch {
|
||||
// 无法解析的任务正文属于真正的投递失败,不按“状态未知”处理。
|
||||
}
|
||||
|
||||
if (statusQueryFailed) {
|
||||
return {
|
||||
title: '钉钉通知状态回查异常',
|
||||
content: `发送给 ${input.recipientName} 的通知已经在通知中心建立任务,但系统暂时无法确认最终状态。请勿重复补发;修复配置后在投递记录中恢复状态回查。`,
|
||||
reason: 'DINGTALK_STATUS_QUERY_FAILED'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: '钉钉通知投递失败',
|
||||
content: `发送给 ${input.recipientName} 的业务通知最终投递失败,请管理员检查通知配置或人员绑定后处理。`,
|
||||
reason: 'DINGTALK_DELIVERY_FAILED'
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDeliveryFailure = async (
|
||||
delivery: NonNullable<Awaited<ReturnType<typeof claimNextDelivery>>>,
|
||||
error: unknown
|
||||
@ -152,7 +277,7 @@ const scheduleDeliveryFailure = async (
|
||||
const failed = details.deterministic || exhausted
|
||||
const delayMinutes = BACKOFF_MINUTES[Math.min(delivery.attempt_count - 1, BACKOFF_MINUTES.length - 1)]
|
||||
|
||||
await updateSendingDeliveryIfLeaseOwned(delivery.id, {
|
||||
const updated = await updateSendingDeliveryIfLeaseOwned(delivery.id, {
|
||||
status: failed ? 'failed' : 'retrying',
|
||||
next_attempt_at: failed
|
||||
? delivery.next_attempt_at
|
||||
@ -162,6 +287,9 @@ const scheduleDeliveryFailure = async (
|
||||
last_error_code: details.code,
|
||||
last_error_message: details.message.slice(0, 2000)
|
||||
})
|
||||
if (updated && failed) {
|
||||
await reportDeliveryFailure(delivery.id)
|
||||
}
|
||||
}
|
||||
|
||||
const parsePayload = (payloadJson: string) => {
|
||||
@ -257,6 +385,13 @@ const processDelivery = async (
|
||||
delivery: NonNullable<Awaited<ReturnType<typeof claimNextDelivery>>>
|
||||
) => {
|
||||
try {
|
||||
const sendConfig = getDingTalkConfigStatus()
|
||||
if (!sendConfig.send_enabled) {
|
||||
throw new DeliveryPayloadError(
|
||||
`通知发送配置不完整: ${sendConfig.missing.join(', ')}`,
|
||||
'CONFIG_INCOMPLETE'
|
||||
)
|
||||
}
|
||||
if (!delivery.recipient_dingtalk_userid?.trim()) {
|
||||
throw new DeliveryPayloadError('收件人未绑定有效的在职员工档案', 'RECIPIENT_UNBOUND')
|
||||
}
|
||||
@ -276,8 +411,9 @@ const processDelivery = async (
|
||||
actionTitle: resolveDeliveryActionTitle(delivery.event_type, payload.actionTitle),
|
||||
actionUrl: payload.actionUrl
|
||||
})
|
||||
await updateSendingDeliveryIfLeaseOwned(delivery.id, {
|
||||
...buildNotificationCenterUpdate(result, null),
|
||||
const update = buildNotificationCenterUpdate(result, null)
|
||||
const updated = await updateSendingDeliveryIfLeaseOwned(delivery.id, {
|
||||
...update,
|
||||
dingtalk_task_id: result.id,
|
||||
payload_json: updatePayloadMetadata(delivery.payload_json, {
|
||||
notification_center_last_status: result.status
|
||||
@ -285,6 +421,9 @@ const processDelivery = async (
|
||||
lease_owner: null,
|
||||
lease_expires_at: null
|
||||
})
|
||||
if (updated && update.status === 'failed') {
|
||||
await reportDeliveryFailure(delivery.id)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
await scheduleDeliveryFailure(delivery, error)
|
||||
}
|
||||
@ -358,8 +497,9 @@ const refreshOneDeliveryResult = async () => {
|
||||
const monotonicResult = monotonicStatus === result.status
|
||||
? result
|
||||
: { ...result, status: monotonicStatus }
|
||||
await updateRefreshDeliveryIfLeaseOwned(delivery.id, {
|
||||
...buildNotificationCenterUpdate(monotonicResult, delivery.sent_at),
|
||||
const update = buildNotificationCenterUpdate(monotonicResult, delivery.sent_at)
|
||||
const updated = await updateRefreshDeliveryIfLeaseOwned(delivery.id, {
|
||||
...update,
|
||||
payload_json: updatePayloadMetadata(delivery.payload_json, {
|
||||
notification_center_last_status: monotonicStatus,
|
||||
notification_center_status_query_error: null
|
||||
@ -367,14 +507,20 @@ const refreshOneDeliveryResult = async () => {
|
||||
lease_owner: null,
|
||||
lease_expires_at: null
|
||||
})
|
||||
if (updated.count === 1 && update.status === 'failed') {
|
||||
await reportDeliveryFailure(delivery.id)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const update = buildRefreshErrorUpdate(error)
|
||||
await updateRefreshDeliveryIfLeaseOwned(delivery.id, {
|
||||
const updated = await updateRefreshDeliveryIfLeaseOwned(delivery.id, {
|
||||
...update,
|
||||
payload_json: updatePayloadMetadata(delivery.payload_json, {
|
||||
notification_center_status_query_error: update.last_error_code
|
||||
})
|
||||
})
|
||||
if (updated.count === 1 && update.status === 'failed') {
|
||||
await reportDeliveryFailure(delivery.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -474,16 +620,17 @@ export const startDingTalkDeliveryWorker = () => {
|
||||
if (deliveryBusy) return
|
||||
deliveryBusy = true
|
||||
try {
|
||||
if (!getNotificationCenterConfigStatus().configured) return
|
||||
const configStatus = getDingTalkConfigStatus()
|
||||
const sendRollout = getDingTalkSendRolloutStatus()
|
||||
if (configStatus.send_enabled && sendRollout.enabled) {
|
||||
if (sendRollout.enabled) {
|
||||
const delivery = await claimNextDelivery(
|
||||
sendRollout.fullRollout ? null : sendRollout.testUserIds
|
||||
)
|
||||
if (delivery) await processDelivery(delivery)
|
||||
}
|
||||
await refreshOneDeliveryResult()
|
||||
if (sendRollout.enabled || getNotificationCenterConfigStatus().configured) {
|
||||
await refreshOneDeliveryResult()
|
||||
}
|
||||
await escalateNextUnreportedFailure()
|
||||
} catch (error) {
|
||||
console.error('[NotificationCenterWorker] 投递循环失败:', error)
|
||||
} finally {
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
import {
|
||||
compactNotificationText,
|
||||
escapeNotificationMarkdownInline,
|
||||
formatBeijingDateTime
|
||||
} from './businessNotificationService'
|
||||
|
||||
export type OpenBorrowAssetPreview = {
|
||||
equipmentId?: number | null
|
||||
borrowRecordId?: number | null
|
||||
@ -114,3 +120,29 @@ export const buildHrSyncPreviewLine = (change: {
|
||||
|
||||
return assetSummary ? `${change.title}(名下资产:${assetSummary})` : change.title
|
||||
}
|
||||
|
||||
export const shouldNotifyHrSync = (
|
||||
status: 'success' | 'failed',
|
||||
changesCount: number
|
||||
) => status === 'failed' || changesCount > 0
|
||||
|
||||
export const buildHrSyncDingTalkContent = (input: {
|
||||
status: 'success' | 'failed'
|
||||
occurredAt: Date
|
||||
changesCount: number
|
||||
summary: string
|
||||
}) => {
|
||||
const resultText = input.status === 'failed'
|
||||
? '同步失败,需要管理员处理'
|
||||
: `发现 ${input.changesCount} 项变化,需要管理员核对`
|
||||
const summary = input.status === 'failed'
|
||||
? '请进入固定资产系统查看错误详情,并检查上游连接或同步配置。'
|
||||
: compactNotificationText(input.summary, '请进入系统查看本次变化明细。', 300)
|
||||
|
||||
return [
|
||||
`**同步时间:** ${formatBeijingDateTime(input.occurredAt)}`,
|
||||
`**执行结果:** ${escapeNotificationMarkdownInline(resultText)}`,
|
||||
`**待处理内容:** ${escapeNotificationMarkdownInline(summary)}`,
|
||||
'> 这是一条需要人工关注的花名册同步汇总。'
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
@ -9,11 +9,18 @@ import {
|
||||
} from '../utils/organization'
|
||||
import {
|
||||
buildHrSyncPreviewLine,
|
||||
buildHrSyncDingTalkContent,
|
||||
resolveHrSyncPersonDisplayName,
|
||||
resolveSourcePersonStatus,
|
||||
shouldNotifyHrSync,
|
||||
shouldUseSourceDepartmentBinding,
|
||||
type OpenBorrowAssetPreview
|
||||
} from './hrSyncHelpers'
|
||||
import {
|
||||
createBusinessNotification,
|
||||
findActiveDingTalkUsersByRoles,
|
||||
findActiveStationUsersByRoles
|
||||
} from './businessNotificationService'
|
||||
|
||||
type SourceDepartment = {
|
||||
sourceGroupId: number | null
|
||||
@ -1113,41 +1120,40 @@ const persistHrSyncArtifacts = async ({
|
||||
})
|
||||
}
|
||||
|
||||
const notification = await tx.notification.create({
|
||||
data: {
|
||||
let notificationId: number | null = null
|
||||
if (shouldNotifyHrSync(status, changes.length)) {
|
||||
const stationRecipients = await findActiveStationUsersByRoles(tx, ['admin'])
|
||||
const dingTalkRecipients = await findActiveDingTalkUsersByRoles(tx, ['admin'])
|
||||
const notification = await createBusinessNotification(tx, {
|
||||
eventType: status === 'failed' ? 'hr_sync_failed' : 'hr_sync_attention',
|
||||
businessType: 'hr_sync',
|
||||
businessId: syncJob.id,
|
||||
title: summary.title,
|
||||
content: summary.content,
|
||||
type: 'hr_sync',
|
||||
level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info',
|
||||
action_url: '/notifications',
|
||||
related_sync_job_id: syncJob.id,
|
||||
metadata: toJsonText({
|
||||
level: status === 'failed' ? 'error' : 'warning',
|
||||
actionUrl: '/notifications',
|
||||
relatedSyncJobId: syncJob.id,
|
||||
metadata: {
|
||||
status,
|
||||
changes_count: changes.length,
|
||||
counts: summary.counts,
|
||||
preview_changes: summary.previewChanges
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const notificationId: number | null = notification.id
|
||||
|
||||
const adminUsers = await tx.user.findMany({
|
||||
where: {
|
||||
role: 'admin',
|
||||
status: 'active'
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
await tx.userNotification.createMany({
|
||||
data: adminUsers.map((user) => ({
|
||||
user_id: user.id,
|
||||
notification_id: notification.id
|
||||
})),
|
||||
skipDuplicates: true
|
||||
},
|
||||
stationRecipients,
|
||||
dingTalkRecipients,
|
||||
dingTalk: {
|
||||
title: status === 'failed' ? '花名册同步失败' : '花名册同步待处理',
|
||||
content: buildHrSyncDingTalkContent({
|
||||
status,
|
||||
occurredAt: new Date(),
|
||||
changesCount: changes.length,
|
||||
summary: summary.content
|
||||
}),
|
||||
actionTitle: '查看同步结果',
|
||||
messageVersion: 'summary-v1'
|
||||
}
|
||||
})
|
||||
notificationId = notification?.id ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
46
backend/src/types/express-session.d.ts
vendored
46
backend/src/types/express-session.d.ts
vendored
@ -1,44 +1,8 @@
|
||||
import 'express-session'
|
||||
|
||||
declare module 'express-session' {
|
||||
import { RequestHandler } from 'express'
|
||||
|
||||
interface SessionOptions {
|
||||
secret: string
|
||||
resave?: boolean
|
||||
saveUninitialized?: boolean
|
||||
store?: Store
|
||||
cookie?: {
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
sameSite?: boolean | 'lax' | 'strict' | 'none'
|
||||
maxAge?: number
|
||||
}
|
||||
interface SessionData {
|
||||
user?: Record<string, unknown>
|
||||
inventoryParticipant?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface Store {
|
||||
get: (sid: string, callback: (err: any, sess: any | null) => void) => void
|
||||
set: (sid: string, sess: any, callback?: (err?: any) => void) => void
|
||||
destroy: (sid: string, callback?: (err?: any) => void) => void
|
||||
}
|
||||
|
||||
class MemoryStore implements Store {
|
||||
get: Store['get']
|
||||
set: Store['set']
|
||||
destroy: Store['destroy']
|
||||
}
|
||||
|
||||
interface Session {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface SessionRequest extends Express.Request {
|
||||
session?: Session
|
||||
}
|
||||
|
||||
function session(options: SessionOptions): RequestHandler
|
||||
|
||||
namespace session {
|
||||
export { MemoryStore }
|
||||
}
|
||||
|
||||
export = session
|
||||
}
|
||||
|
||||
87
backend/src/utils/__tests__/sessionStore.test.ts
Normal file
87
backend/src/utils/__tests__/sessionStore.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import prisma from '../prisma'
|
||||
import {
|
||||
PrismaSessionStore,
|
||||
deserializeSession,
|
||||
resolveSessionExpiresAt,
|
||||
serializeSession
|
||||
} from '../sessionStore'
|
||||
|
||||
test('会话过期时间优先使用cookie.expires并兼容maxAge', () => {
|
||||
const now = new Date('2026-07-16T00:00:00.000Z')
|
||||
const explicit = resolveSessionExpiresAt({
|
||||
cookie: { expires: new Date('2026-07-16T02:00:00.000Z') } as any
|
||||
}, now)
|
||||
const byMaxAge = resolveSessionExpiresAt({ cookie: { maxAge: 30_000 } as any }, now)
|
||||
|
||||
assert.equal(explicit.toISOString(), '2026-07-16T02:00:00.000Z')
|
||||
assert.equal(byMaxAge.toISOString(), '2026-07-16T00:00:30.000Z')
|
||||
})
|
||||
|
||||
test('会话序列化往返保留登录身份', () => {
|
||||
const source = {
|
||||
cookie: { maxAge: 60_000 },
|
||||
user: { id: 7, role: 'it' }
|
||||
} as any
|
||||
assert.deepEqual(deserializeSession(serializeSession(source)), source)
|
||||
assert.throws(() => deserializeSession('[]'), /会话内容无效/)
|
||||
})
|
||||
|
||||
test('持久化Store会读取有效会话并删除已过期会话', async () => {
|
||||
const originalFindUnique = prisma.webSession.findUnique
|
||||
const originalDeleteMany = prisma.webSession.deleteMany
|
||||
let stored: any = {
|
||||
data: JSON.stringify({ cookie: { maxAge: 60_000 }, user: { id: 9 } }),
|
||||
expires_at: new Date(Date.now() + 60_000)
|
||||
}
|
||||
let deleteCount = 0
|
||||
;(prisma.webSession.findUnique as any) = async () => stored
|
||||
;(prisma.webSession.deleteMany as any) = async () => {
|
||||
deleteCount += 1
|
||||
return { count: 1 }
|
||||
}
|
||||
|
||||
try {
|
||||
const store = new PrismaSessionStore()
|
||||
const active = await new Promise<any>((resolve, reject) => {
|
||||
store.get('active', (error, value) => error ? reject(error) : resolve(value))
|
||||
})
|
||||
assert.equal(active.user.id, 9)
|
||||
assert.equal(deleteCount, 0)
|
||||
|
||||
stored = { ...stored, expires_at: new Date(Date.now() - 1000) }
|
||||
const expired = await new Promise<any>((resolve, reject) => {
|
||||
store.get('expired', (error, value) => error ? reject(error) : resolve(value))
|
||||
})
|
||||
assert.equal(expired, null)
|
||||
assert.equal(deleteCount, 1)
|
||||
} finally {
|
||||
;(prisma.webSession.findUnique as any) = originalFindUnique
|
||||
;(prisma.webSession.deleteMany as any) = originalDeleteMany
|
||||
}
|
||||
})
|
||||
|
||||
test('touch只续期而不以旧请求覆盖已更新的会话内容', async () => {
|
||||
const originalUpdateMany = prisma.webSession.updateMany
|
||||
let updateArgs: any
|
||||
;(prisma.webSession.updateMany as any) = async (args: any) => {
|
||||
updateArgs = args
|
||||
return { count: 1 }
|
||||
}
|
||||
|
||||
try {
|
||||
const store = new PrismaSessionStore()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
store.touch('sid-1', {
|
||||
cookie: { maxAge: 60_000 },
|
||||
user: { id: 1, stale: true }
|
||||
} as any, (error) => error ? reject(error) : resolve())
|
||||
})
|
||||
assert.ok(updateArgs.data.expires_at instanceof Date)
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(updateArgs.data, 'data'), false)
|
||||
} finally {
|
||||
;(prisma.webSession.updateMany as any) = originalUpdateMany
|
||||
}
|
||||
})
|
||||
27
backend/src/utils/__tests__/stage2Migration.test.ts
Normal file
27
backend/src/utils/__tests__/stage2Migration.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const migrationPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../prisma/migrations/20260716_mainline_stage2_notifications_sessions/migration.sql'
|
||||
)
|
||||
|
||||
test('第二阶段迁移创建持久化会话并增加投递失败告警标记', () => {
|
||||
const sql = readFileSync(migrationPath, 'utf8')
|
||||
assert.match(sql, /CREATE TABLE `web_session`/i)
|
||||
assert.match(sql, /`sid` VARCHAR\(191\) CHARACTER SET ascii COLLATE ascii_bin NOT NULL/i)
|
||||
assert.match(sql, /`expires_at` DATETIME\(3\) NOT NULL/i)
|
||||
assert.match(sql, /ADD COLUMN `failure_notified_at` DATETIME\(3\) NULL/i)
|
||||
assert.match(sql, /SET `failure_notified_at` = COALESCE\(`updated_at`, `created_at`/i)
|
||||
assert.match(sql, /WHERE `status` = 'failed'/i)
|
||||
})
|
||||
|
||||
test('第二阶段迁移只归档旧花名册未读消息且不删除记录', () => {
|
||||
const sql = readFileSync(migrationPath, 'utf8')
|
||||
assert.match(sql, /notification\.`type` = 'hr_sync'/i)
|
||||
assert.match(sql, /user_notification\.`is_read` = 0/i)
|
||||
assert.match(sql, /user_notification\.`is_read` = 1/i)
|
||||
assert.doesNotMatch(sql, /DELETE\s+FROM\s+`?user_notification`?/i)
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import session from 'express-session'
|
||||
import Keycloak from 'keycloak-connect'
|
||||
import { assertKeycloakConfiguration } from './authMode'
|
||||
import { persistentSessionStore } from './sessionStore'
|
||||
|
||||
type KeycloakEnvConfig = {
|
||||
serverUrl: string
|
||||
@ -18,11 +18,9 @@ const keycloakConfig: KeycloakEnvConfig = {
|
||||
clientSecret: validatedKeycloakConfig?.clientSecret
|
||||
}
|
||||
|
||||
const memoryStore = new session.MemoryStore()
|
||||
|
||||
const keycloak = new Keycloak(
|
||||
{
|
||||
store: memoryStore,
|
||||
store: persistentSessionStore,
|
||||
scope: 'openid profile email'
|
||||
},
|
||||
{
|
||||
@ -41,4 +39,4 @@ const keycloak = new Keycloak(
|
||||
} as any
|
||||
)
|
||||
|
||||
export { keycloak, memoryStore }
|
||||
export { keycloak }
|
||||
|
||||
122
backend/src/utils/sessionStore.ts
Normal file
122
backend/src/utils/sessionStore.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import session, { type SessionData } from 'express-session'
|
||||
import prisma from './prisma'
|
||||
|
||||
const DEFAULT_SESSION_MAX_AGE_MS = 60 * 60 * 1000
|
||||
const CLEANUP_INTERVAL_MS = 15 * 60 * 1000
|
||||
|
||||
type StoreCallback = (error?: unknown) => void
|
||||
type StoreGetCallback = (error: unknown, session?: SessionData | null) => void
|
||||
|
||||
const finish = (callback: StoreCallback | undefined, error?: unknown) => {
|
||||
if (callback) callback(error)
|
||||
}
|
||||
|
||||
export const resolveSessionExpiresAt = (
|
||||
sessionData: Partial<SessionData> | null | undefined,
|
||||
now: Date = new Date()
|
||||
) => {
|
||||
const cookie = sessionData?.cookie as SessionData['cookie'] | undefined
|
||||
const explicitExpires = cookie?.expires ? new Date(cookie.expires) : null
|
||||
if (explicitExpires && !Number.isNaN(explicitExpires.getTime())) {
|
||||
return explicitExpires
|
||||
}
|
||||
|
||||
const maxAge = Number(cookie?.maxAge)
|
||||
return new Date(
|
||||
now.getTime() + (Number.isFinite(maxAge) && maxAge > 0 ? maxAge : DEFAULT_SESSION_MAX_AGE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
export const serializeSession = (sessionData: SessionData) => JSON.stringify(sessionData)
|
||||
|
||||
export const deserializeSession = (value: string): SessionData => {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('持久化会话内容无效')
|
||||
}
|
||||
return parsed as SessionData
|
||||
}
|
||||
|
||||
export class PrismaSessionStore extends session.Store {
|
||||
get(sid: string, callback: StoreGetCallback) {
|
||||
void (async () => {
|
||||
try {
|
||||
const stored = await prisma.webSession.findUnique({
|
||||
where: { sid },
|
||||
select: { data: true, expires_at: true }
|
||||
})
|
||||
if (!stored) {
|
||||
callback(null, null)
|
||||
return
|
||||
}
|
||||
if (stored.expires_at.getTime() <= Date.now()) {
|
||||
await prisma.webSession.deleteMany({ where: { sid } })
|
||||
callback(null, null)
|
||||
return
|
||||
}
|
||||
callback(null, deserializeSession(stored.data))
|
||||
} catch (error) {
|
||||
callback(error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
set(sid: string, sessionData: SessionData, callback?: StoreCallback) {
|
||||
void (async () => {
|
||||
try {
|
||||
const data = serializeSession(sessionData)
|
||||
const expiresAt = resolveSessionExpiresAt(sessionData)
|
||||
await prisma.webSession.upsert({
|
||||
where: { sid },
|
||||
create: { sid, data, expires_at: expiresAt },
|
||||
update: { data, expires_at: expiresAt }
|
||||
})
|
||||
finish(callback)
|
||||
} catch (error) {
|
||||
finish(callback, error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
destroy(sid: string, callback?: StoreCallback) {
|
||||
void (async () => {
|
||||
try {
|
||||
await prisma.webSession.deleteMany({ where: { sid } })
|
||||
finish(callback)
|
||||
} catch (error) {
|
||||
finish(callback, error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
touch(sid: string, sessionData: SessionData, callback?: StoreCallback) {
|
||||
void (async () => {
|
||||
try {
|
||||
await prisma.webSession.updateMany({
|
||||
where: { sid, expires_at: { gt: new Date() } },
|
||||
data: {
|
||||
expires_at: resolveSessionExpiresAt(sessionData)
|
||||
}
|
||||
})
|
||||
finish(callback)
|
||||
} catch (error) {
|
||||
finish(callback, error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
export const persistentSessionStore = new PrismaSessionStore()
|
||||
|
||||
export const startSessionCleanupWorker = () => {
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await prisma.webSession.deleteMany({ where: { expires_at: { lte: new Date() } } })
|
||||
} catch (error) {
|
||||
console.error('[session] 清理过期会话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(cleanup, 30_000).unref()
|
||||
setInterval(cleanup, CLEANUP_INTERVAL_MS).unref()
|
||||
}
|
||||
54
frontend/scripts/test-phase-two-presentation.ts
Normal file
54
frontend/scripts/test-phase-two-presentation.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
getDashboardRoleActions,
|
||||
getDashboardRoleTitle
|
||||
} from '../src/views/dashboard/dashboardRoleConfig.ts'
|
||||
import {
|
||||
getNotificationActionLabel,
|
||||
getNotificationRecordBadge,
|
||||
isNotificationAttentionRequired
|
||||
} from '../src/views/notifications/notificationPresentation.ts'
|
||||
import { getUserPersonOptionAvailability } from '../src/utils/userPersonOption.ts'
|
||||
|
||||
const administrativeActions = getDashboardRoleActions('administrative')
|
||||
assert.ok(administrativeActions.some((action) => action.id === 'asset-create'), '行政首页应突出资产入库')
|
||||
assert.ok(administrativeActions.some((action) => action.id === 'inventory'), '行政首页应提供盘点入口')
|
||||
assert.ok(administrativeActions.some((action) => action.id === 'disposal-approval'), '行政首页应提供报废审批入口')
|
||||
|
||||
const itActions = getDashboardRoleActions('it')
|
||||
assert.ok(itActions.some((action) => action.id === 'borrow'), 'IT 首页应提供借出入口')
|
||||
assert.ok(itActions.some((action) => action.id === 'transfer'), 'IT 首页应提供调拨入口')
|
||||
assert.ok(itActions.some((action) => action.id === 'maintenance'), 'IT 首页应提供维修入口')
|
||||
assert.equal(getDashboardRoleTitle('admin'), '管理总览')
|
||||
|
||||
const availablePerson = {
|
||||
id: 1,
|
||||
real_name: '测试员工',
|
||||
nickname: '测试',
|
||||
status: 'active' as const,
|
||||
dingtalk_ready: true,
|
||||
is_bound: false,
|
||||
bound_user: null
|
||||
}
|
||||
assert.equal(getUserPersonOptionAvailability(availablePerson).selectable, true, '在职且钉钉就绪的未绑定员工应可选择')
|
||||
|
||||
const boundToOther = {
|
||||
...availablePerson,
|
||||
is_bound: true,
|
||||
bound_user: { id: 9, username: 'other', name: '其他账号', role: 'it', status: 'active' as const }
|
||||
}
|
||||
assert.equal(getUserPersonOptionAvailability(boundToOther, 10).selectable, false, '已绑定其他账号的员工不可重复选择')
|
||||
assert.equal(getUserPersonOptionAvailability(boundToOther, 9).selectable, true, '编辑当前账号时应保留自己的员工绑定')
|
||||
assert.equal(getUserPersonOptionAvailability({ ...availablePerson, dingtalk_ready: false }).selectable, false)
|
||||
|
||||
const ordinaryRecord = { level: 'info', related_sync_job_id: null, action_url: '/workflows/disposals/1' }
|
||||
assert.equal(getNotificationRecordBadge(ordinaryRecord).label, '系统记录', '普通流程记录不应强调未读')
|
||||
assert.equal(isNotificationAttentionRequired(ordinaryRecord), false)
|
||||
assert.equal(getNotificationActionLabel(ordinaryRecord), '查看相关记录')
|
||||
|
||||
const errorRecord = { level: 'error', related_sync_job_id: null, action_url: '/settings' }
|
||||
assert.equal(getNotificationRecordBadge(errorRecord).label, '异常待处理')
|
||||
assert.equal(isNotificationAttentionRequired(errorRecord), true)
|
||||
assert.equal(getNotificationActionLabel(errorRecord), '前往处理')
|
||||
|
||||
console.log('phase two presentation tests: ok')
|
||||
@ -1,5 +1,5 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
export interface DisposalRecord {
|
||||
id: number
|
||||
@ -75,36 +75,44 @@ export interface CreateDisposalParams {
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// 创建报废申请
|
||||
export const createDisposal = (data: CreateDisposalParams) => {
|
||||
return request.post('/disposal', data)
|
||||
export interface DisposalSummary {
|
||||
total: number
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
// 获取报废记录列表
|
||||
export const getDisposalList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number; scope?: 'pending' | 'mine' | 'processed' | 'all' }) => {
|
||||
return request
|
||||
.get<ApiResponse<PaginatedResponse<DisposalRecord>>>('/disposal/list', { params })
|
||||
.then((response) => response.data)
|
||||
export interface DisposalListData {
|
||||
list: DisposalRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
summary: DisposalSummary
|
||||
}
|
||||
|
||||
// 获取报废记录详情
|
||||
export const getDisposalDetail = (id: number) => {
|
||||
return request
|
||||
.get<ApiResponse<DisposalRecord>>(`/disposal/${id}`)
|
||||
.then((response) => response.data)
|
||||
}
|
||||
export const createDisposal = (data: CreateDisposalParams) => request.post('/disposal', data)
|
||||
|
||||
// 审批报废申请
|
||||
export const approveDisposal = (id: number, data: { approved: boolean; reject_reason?: string }) => {
|
||||
return request.put(`/disposal/${id}/approve`, data)
|
||||
}
|
||||
export const getDisposalList = (params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
equipment_id?: number
|
||||
scope?: 'pending' | 'mine' | 'processed' | 'all'
|
||||
}) => request
|
||||
.get<ApiResponse<DisposalListData>>('/disposal/list', { params })
|
||||
.then((response) => response.data)
|
||||
|
||||
// 完成报废处置
|
||||
export const completeDisposal = (id: number, data: { actual_value?: number; completion_notes?: string }) => {
|
||||
return request.put(`/disposal/${id}/complete`, data)
|
||||
}
|
||||
export const getDisposalDetail = (id: number) => request
|
||||
.get<ApiResponse<DisposalRecord>>(`/disposal/${id}`)
|
||||
.then((response) => response.data)
|
||||
|
||||
// 获取资产报废历史
|
||||
export const getEquipmentDisposalHistory = (equipmentId: number) => {
|
||||
return request.get(`/disposal/equipment/${equipmentId}/history`)
|
||||
}
|
||||
export const approveDisposal = (id: number, data: { approved: boolean; reject_reason?: string }) =>
|
||||
request.put(`/disposal/${id}/approve`, data)
|
||||
|
||||
export const completeDisposal = (id: number, data: { actual_value?: number; completion_notes?: string }) =>
|
||||
request.put(`/disposal/${id}/complete`, data)
|
||||
|
||||
export const getEquipmentDisposalHistory = (equipmentId: number) =>
|
||||
request.get(`/disposal/equipment/${equipmentId}/history`)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
export interface MaintenanceRecord {
|
||||
id: number
|
||||
@ -30,39 +30,43 @@ export interface CreateMaintenanceParams {
|
||||
expected_end_date?: string
|
||||
}
|
||||
|
||||
// 创建维修记录
|
||||
export const createMaintenance = (data: CreateMaintenanceParams) => {
|
||||
return request.post('/maintenance', data)
|
||||
export interface MaintenanceSummary {
|
||||
total: number
|
||||
in_progress: number
|
||||
completed: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
// 获取维修记录列表
|
||||
export const getMaintenanceList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => {
|
||||
return request
|
||||
.get<ApiResponse<PaginatedResponse<MaintenanceRecord>>>('/maintenance/list', { params })
|
||||
.then((response) => response.data)
|
||||
export interface MaintenanceListData {
|
||||
list: MaintenanceRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
summary: MaintenanceSummary
|
||||
}
|
||||
|
||||
// 获取维修记录详情
|
||||
export const getMaintenanceDetail = (id: number) => {
|
||||
return request.get(`/maintenance/${id}`)
|
||||
}
|
||||
export const createMaintenance = (data: CreateMaintenanceParams) => request.post('/maintenance', data)
|
||||
|
||||
// 更新维修记录
|
||||
export const updateMaintenance = (id: number, data: Partial<CreateMaintenanceParams>) => {
|
||||
return request.put(`/maintenance/${id}`, data)
|
||||
}
|
||||
export const getMaintenanceList = (params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
equipment_id?: number
|
||||
}) => request
|
||||
.get<ApiResponse<MaintenanceListData>>('/maintenance/list', { params })
|
||||
.then((response) => response.data)
|
||||
|
||||
// 完成维修
|
||||
export const completeMaintenance = (id: number, data: { actual_cost?: number; repair_result?: string }) => {
|
||||
return request.put(`/maintenance/${id}/complete`, data)
|
||||
}
|
||||
export const getMaintenanceDetail = (id: number) => request.get(`/maintenance/${id}`)
|
||||
|
||||
// 取消维修
|
||||
export const cancelMaintenance = (id: number, data: { reason?: string }) => {
|
||||
return request.put(`/maintenance/${id}/cancel`, data)
|
||||
}
|
||||
export const updateMaintenance = (id: number, data: Partial<CreateMaintenanceParams>) =>
|
||||
request.put(`/maintenance/${id}`, data)
|
||||
|
||||
// 获取资产维修历史
|
||||
export const getEquipmentMaintenanceHistory = (equipmentId: number) => {
|
||||
return request.get(`/maintenance/equipment/${equipmentId}/history`)
|
||||
}
|
||||
export const completeMaintenance = (id: number, data: { actual_cost?: number; repair_result?: string }) =>
|
||||
request.put(`/maintenance/${id}/complete`, data)
|
||||
|
||||
export const cancelMaintenance = (id: number, data: { reason?: string }) =>
|
||||
request.put(`/maintenance/${id}/cancel`, data)
|
||||
|
||||
export const getEquipmentMaintenanceHistory = (equipmentId: number) =>
|
||||
request.get(`/maintenance/equipment/${equipmentId}/history`)
|
||||
|
||||
@ -1,6 +1,24 @@
|
||||
import api from './index'
|
||||
import type { ApiResponse, BoundPersonSummary, Equipment, EquipmentTreeNode } from '@/types'
|
||||
|
||||
export interface MyAssetAccessory {
|
||||
id: number
|
||||
equipment_code: string
|
||||
equipment_name: string
|
||||
equipment_model?: string | null
|
||||
equipment_type: string
|
||||
status?: string | null
|
||||
parent_id?: number | null
|
||||
parent_equipment_id?: number | null
|
||||
root_equipment_id?: number | null
|
||||
depth: number
|
||||
mount_status?: string | null
|
||||
department_id?: number | null
|
||||
department?: { id: number; name: string } | null
|
||||
storage_location_id?: number | null
|
||||
storageLocation?: { id: number; name: string; code?: string | null } | null
|
||||
}
|
||||
|
||||
export interface MyAssetRecord {
|
||||
borrow_record_id: number
|
||||
borrow_time: string
|
||||
@ -8,6 +26,10 @@ export interface MyAssetRecord {
|
||||
is_long_term?: boolean
|
||||
equipment: Equipment
|
||||
children?: EquipmentTreeNode[]
|
||||
current_accessories: MyAssetAccessory[]
|
||||
borrowed_accessories_snapshot: MyAssetAccessory[]
|
||||
borrowed_accessories_snapshot_valid: boolean
|
||||
accessory_relationship_changed: boolean
|
||||
}
|
||||
|
||||
export interface MyAssetsResponse {
|
||||
|
||||
@ -10,11 +10,54 @@ export interface UserListParams {
|
||||
person_id?: number | null
|
||||
}
|
||||
|
||||
export interface UserPersonOptionBoundUser {
|
||||
id: number | string
|
||||
username: string
|
||||
name: string
|
||||
role: string
|
||||
status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
export interface UserPersonOption {
|
||||
id: number
|
||||
real_name: string
|
||||
nickname: string
|
||||
job_number?: string | null
|
||||
status: 'active' | 'inactive'
|
||||
main_department?: { id: number; name: string } | null
|
||||
dingtalk_ready: boolean
|
||||
is_bound: boolean
|
||||
bound_user?: UserPersonOptionBoundUser | null
|
||||
}
|
||||
|
||||
export interface UserPersonOptionListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: 'active' | 'inactive'
|
||||
bound?: 'all' | 'bound' | 'unbound'
|
||||
dingtalkReady?: boolean
|
||||
}
|
||||
|
||||
export const getUserList = async (params: UserListParams = {}) => {
|
||||
const response = await api.get<ApiResponse<PaginatedResponse<User>>>('/users', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getUserPersonOptions = async (params: UserPersonOptionListParams = {}) => {
|
||||
const response = await api.get<ApiResponse<PaginatedResponse<UserPersonOption>>>('/users/person-options', {
|
||||
params: {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
keyword: params.keyword,
|
||||
status: params.status,
|
||||
bound: params.bound,
|
||||
dingtalk_ready: typeof params.dingtalkReady === 'boolean' ? String(params.dingtalkReady) : undefined
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createUser = async (data: {
|
||||
username: string
|
||||
password: string
|
||||
@ -58,7 +101,6 @@ export const resetUserPassword = async (id: number | string, newPassword: string
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
export const deleteUser = async (id: number | string) => {
|
||||
const response = await api.delete<ApiResponse<null>>(`/users/${id}`)
|
||||
return response.data
|
||||
|
||||
55
frontend/src/components/MobileActionSheet.vue
Normal file
55
frontend/src/components/MobileActionSheet.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="sheet-backdrop md:hidden"
|
||||
role="presentation"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<section
|
||||
class="sheet-panel max-w-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
||||
<p v-if="description" class="mt-1 text-xs text-text-tertiary">{{ description }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-icon" aria-label="关闭操作抽屉" @click="emit('close')">
|
||||
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
}>(), {
|
||||
title: '更多操作',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
@ -8,6 +8,12 @@
|
||||
<p class="text-xs text-text-tertiary">当前绑定</p>
|
||||
<p class="mt-1 truncate text-sm font-medium text-text-primary">{{ selectedLabel }}</p>
|
||||
<p v-if="selectedMeta" class="mt-1 text-xs text-text-secondary">{{ selectedMeta }}</p>
|
||||
<div v-if="selectedPerson" class="mt-2 flex flex-wrap gap-2">
|
||||
<span class="badge" :class="selectedAvailability.badgeClass">{{ selectedAvailability.label }}</span>
|
||||
<span class="badge" :class="selectedPerson.dingtalk_ready ? 'badge-success' : 'badge-warning'">
|
||||
{{ selectedPerson.dingtalk_ready ? '钉钉已就绪' : '钉钉未就绪' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary shrink-0" @click="clearSelection">
|
||||
{{ clearLabel }}
|
||||
@ -55,8 +61,8 @@
|
||||
<span class="block truncate text-sm font-medium text-text-primary">{{ formatName(person) }}</span>
|
||||
<span class="mt-1 block truncate text-xs text-text-secondary">{{ formatMeta(person) }}</span>
|
||||
</span>
|
||||
<span :class="['badge shrink-0', isSelectable(person) ? 'badge-success' : 'badge-neutral']">
|
||||
{{ isSelectable(person) ? '可绑定' : getUnavailableReason(person) }}
|
||||
<span :class="['badge shrink-0', getAvailability(person).badgeClass]">
|
||||
{{ getAvailability(person).label }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -79,26 +85,29 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||
import { getUserPersonOptions, type UserPersonOption } from '@/api/users'
|
||||
import { getUserPersonOptionAvailability } from '@/utils/userPersonOption'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
selectedPerson?: PersonOption | null
|
||||
selectedPerson?: UserPersonOption | null
|
||||
currentUserId?: number | string | null
|
||||
clearLabel?: string
|
||||
}>(), {
|
||||
selectedPerson: null,
|
||||
currentUserId: null,
|
||||
clearLabel: '取消绑定'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'update:selectedPerson', value: PersonOption | null): void
|
||||
(event: 'update:selectedPerson', value: UserPersonOption | null): void
|
||||
}>()
|
||||
|
||||
const keyword = ref('')
|
||||
const results = ref<PersonOption[]>([])
|
||||
const results = ref<UserPersonOption[]>([])
|
||||
const page = ref(0)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
@ -110,19 +119,19 @@ let requestSequence = 0
|
||||
const normalizedKeyword = computed(() => keyword.value.trim())
|
||||
const hasMore = computed(() => results.value.length < total.value)
|
||||
|
||||
const formatName = (person: PersonOption) => {
|
||||
const formatName = (person: UserPersonOption) => {
|
||||
if (person.nickname && person.real_name && person.nickname !== person.real_name) {
|
||||
return `${person.nickname}(${person.real_name})`
|
||||
}
|
||||
return person.nickname || person.real_name || `员工 #${person.id}`
|
||||
}
|
||||
|
||||
const formatMeta = (person: PersonOption) => {
|
||||
const departments = Array.isArray(person.department_names)
|
||||
? person.department_names.filter(Boolean)
|
||||
: []
|
||||
const department = departments.join(' / ') || person.main_department?.name || person.department
|
||||
const formatMeta = (person: UserPersonOption) => {
|
||||
const department = person.main_department?.name
|
||||
const parts = [department, person.job_number ? `工号 ${person.job_number}` : ''].filter(Boolean)
|
||||
if (person.is_bound && person.bound_user) {
|
||||
parts.push(`已绑定 ${person.bound_user.name}(${person.bound_user.username})`)
|
||||
}
|
||||
return parts.join(' · ') || (person.status === 'active' ? '在职员工' : '已停用员工')
|
||||
}
|
||||
|
||||
@ -138,13 +147,14 @@ const selectedMeta = computed(() => {
|
||||
return formatMeta(props.selectedPerson)
|
||||
})
|
||||
|
||||
const isSelectable = (person: PersonOption) =>
|
||||
person.status === 'active' && person.dingtalk_ready
|
||||
const getAvailability = (person: UserPersonOption) =>
|
||||
getUserPersonOptionAvailability(person, props.currentUserId)
|
||||
|
||||
const getUnavailableReason = (person: PersonOption) => {
|
||||
if (person.status !== 'active') return '已停用'
|
||||
return '钉钉身份不可用'
|
||||
}
|
||||
const selectedAvailability = computed(() => props.selectedPerson
|
||||
? getAvailability(props.selectedPerson)
|
||||
: { selectable: false, label: '绑定状态未知', badgeClass: 'badge-neutral' })
|
||||
|
||||
const isSelectable = (person: UserPersonOption) => getAvailability(person).selectable
|
||||
|
||||
const resetResults = () => {
|
||||
requestSequence += 1
|
||||
@ -171,11 +181,11 @@ const search = async (reset: boolean) => {
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await getPersonOptions({
|
||||
const response = await getUserPersonOptions({
|
||||
page: targetPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: searchKeyword,
|
||||
includeInactive: true
|
||||
bound: 'all'
|
||||
})
|
||||
if (requestId !== requestSequence || searchKeyword !== normalizedKeyword.value) return
|
||||
|
||||
@ -197,7 +207,7 @@ const search = async (reset: boolean) => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectPerson = (person: PersonOption) => {
|
||||
const selectPerson = (person: UserPersonOption) => {
|
||||
if (!isSelectable(person)) return
|
||||
emit('update:modelValue', String(person.id))
|
||||
emit('update:selectedPerson', person)
|
||||
|
||||
@ -101,7 +101,7 @@
|
||||
v-if="authStore.can(PERMISSIONS.NOTIFICATIONS_VIEW)"
|
||||
type="button"
|
||||
class="btn-icon relative"
|
||||
title="站内消息"
|
||||
title="系统记录"
|
||||
@click="router.push('/notifications')"
|
||||
>
|
||||
<BellIcon class="h-5 w-5" />
|
||||
@ -171,7 +171,7 @@ const navItems = [
|
||||
anyPermissions: [PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE]
|
||||
},
|
||||
{ path: '/statistics', title: '统计', icon: ChartBarIcon, permission: PERMISSIONS.REPORTS_VIEW },
|
||||
{ path: '/notifications', title: '消息', icon: BellIcon, permission: PERMISSIONS.NOTIFICATIONS_VIEW },
|
||||
{ path: '/notifications', title: '系统记录', icon: BellIcon, permission: PERMISSIONS.NOTIFICATIONS_VIEW },
|
||||
{ path: '/settings', title: '设置', icon: Cog6ToothIcon, permission: PERMISSIONS.SYSTEM_SETTINGS }
|
||||
]
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('../views/notifications/NotificationCenter.vue'),
|
||||
meta: { title: '站内消息', permission: PERMISSIONS.NOTIFICATIONS_VIEW }
|
||||
meta: { title: '系统记录', permission: PERMISSIONS.NOTIFICATIONS_VIEW }
|
||||
},
|
||||
{
|
||||
path: 'workflows/disposals',
|
||||
|
||||
29
frontend/src/utils/userPersonOption.ts
Normal file
29
frontend/src/utils/userPersonOption.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { UserPersonOption } from '@/api/users'
|
||||
|
||||
export interface UserPersonOptionAvailability {
|
||||
selectable: boolean
|
||||
label: string
|
||||
badgeClass: string
|
||||
}
|
||||
|
||||
const sameUserId = (left?: number | string | null, right?: number | string | null) =>
|
||||
left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right)
|
||||
|
||||
export const getUserPersonOptionAvailability = (
|
||||
person: UserPersonOption,
|
||||
currentUserId?: number | string | null
|
||||
): UserPersonOptionAvailability => {
|
||||
if (person.status !== 'active') {
|
||||
return { selectable: false, label: '已停用', badgeClass: 'badge-neutral' }
|
||||
}
|
||||
if (!person.dingtalk_ready) {
|
||||
return { selectable: false, label: '钉钉未就绪', badgeClass: 'badge-warning' }
|
||||
}
|
||||
if (person.is_bound && !sameUserId(person.bound_user?.id, currentUserId)) {
|
||||
return { selectable: false, label: '已绑定其他账号', badgeClass: 'badge-neutral' }
|
||||
}
|
||||
if (person.is_bound) {
|
||||
return { selectable: true, label: '当前账号', badgeClass: 'badge-success' }
|
||||
}
|
||||
return { selectable: true, label: '可绑定', badgeClass: 'badge-success' }
|
||||
}
|
||||
@ -14,6 +14,14 @@
|
||||
<p class="page-meta__label">员工绑定</p>
|
||||
<p class="page-meta__value text-base">{{ personLabel }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">当前配件</p>
|
||||
<p class="page-meta__value">{{ currentAccessoryCount }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">借出快照</p>
|
||||
<p class="page-meta__value">{{ borrowedSnapshotCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -48,18 +56,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="record.children?.length" class="mt-4 border-t border-border pt-4">
|
||||
<h3 class="section-title">随借配件</h3>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div v-for="child in record.children" :key="child.id" class="panel flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm text-text-primary">{{ child.equipment_name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ child.equipment_code }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-text-secondary">{{ child.equipment_type }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="record.accessory_relationship_changed"
|
||||
class="mt-4 rounded-lg border border-status-warning/30 bg-status-warning/10 px-4 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-warning">配件关系已变化</span>
|
||||
<p class="text-xs text-status-warning">当前挂载关系与借出时快照不同,请以两个区域分别核对。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 border-t border-border pt-4 xl:grid-cols-2">
|
||||
<section class="panel">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-primary">当前配件树</h3>
|
||||
<p class="mt-1 text-xs text-text-tertiary">展示此刻系统中的递归挂载关系</p>
|
||||
</div>
|
||||
<span class="badge badge-neutral">{{ record.current_accessories.length }} 件</span>
|
||||
</div>
|
||||
<div v-if="record.current_accessories.length > 0" class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="child in record.current_accessories"
|
||||
:key="`current-${child.id}`"
|
||||
class="rounded-lg border border-border bg-bg-secondary px-3 py-2"
|
||||
:style="getAccessoryIndent(child.depth)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm text-text-primary">
|
||||
<span v-if="child.depth > 1" class="text-text-tertiary">↳ </span>{{ child.equipment_name }}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ child.equipment_code }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-text-secondary">{{ child.equipment_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="mt-3 text-sm text-text-tertiary">当前没有挂载配件。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-primary">借出时配件快照</h3>
|
||||
<p class="mt-1 text-xs text-text-tertiary">历史只读记录,不随之后的挂载变化</p>
|
||||
</div>
|
||||
<span class="badge badge-neutral">{{ record.borrowed_accessories_snapshot.length }} 件</span>
|
||||
</div>
|
||||
<div v-if="!record.borrowed_accessories_snapshot_valid" class="mt-3 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-3">
|
||||
<p class="text-xs text-status-warning">历史快照无法解析,请联系管理员核查这条借用记录。</p>
|
||||
</div>
|
||||
<div v-else-if="record.borrowed_accessories_snapshot.length > 0" class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="child in record.borrowed_accessories_snapshot"
|
||||
:key="`snapshot-${child.id}`"
|
||||
class="rounded-lg border border-border bg-bg-secondary px-3 py-2"
|
||||
:style="getAccessoryIndent(child.depth)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm text-text-primary">
|
||||
<span v-if="child.depth > 1" class="text-text-tertiary">↳ </span>{{ child.equipment_name }}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ child.equipment_code }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-text-secondary">{{ child.equipment_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="mt-3 text-sm text-text-tertiary">借出时没有随借配件。</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
@ -75,6 +143,12 @@ const loading = ref(false)
|
||||
const errorText = ref('')
|
||||
const person = ref<BoundPersonSummary | null>(null)
|
||||
const assets = ref<MyAssetRecord[]>([])
|
||||
const currentAccessoryCount = computed(() =>
|
||||
assets.value.reduce((sum, record) => sum + record.current_accessories.length, 0)
|
||||
)
|
||||
const borrowedSnapshotCount = computed(() =>
|
||||
assets.value.reduce((sum, record) => sum + record.borrowed_accessories_snapshot.length, 0)
|
||||
)
|
||||
|
||||
const personLabel = computed(() => {
|
||||
if (!person.value) return '未绑定'
|
||||
@ -89,6 +163,10 @@ const getReturnLabel = (record: MyAssetRecord) => {
|
||||
return record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置'
|
||||
}
|
||||
|
||||
const getAccessoryIndent = (depth: number) => ({
|
||||
marginLeft: `${Math.min(Math.max(depth - 1, 0), 4) * 12}px`
|
||||
})
|
||||
|
||||
const loadAssets = async () => {
|
||||
loading.value = true
|
||||
errorText.value = ''
|
||||
|
||||
@ -240,14 +240,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
没有对应人员?前往
|
||||
<router-link
|
||||
:to="{ path: '/settings', query: { tab: 'persons' } }"
|
||||
class="font-medium text-accent hover:underline"
|
||||
>
|
||||
人员维护
|
||||
</router-link>
|
||||
新增。
|
||||
没有对应人员时,请联系管理员维护花名册和员工同步状态。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,41 +18,51 @@
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Overview</p>
|
||||
<h2 class="page-title">资产运行总览</h2>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<router-link v-if="canBorrow" to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
|
||||
<router-link v-if="canBorrow" to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
|
||||
<router-link v-if="canViewAllAssets" to="/equipment" class="btn-secondary text-sm">资产管理</router-link>
|
||||
<router-link v-if="canApproveDisposals" to="/workflows/disposals?scope=pending" class="btn-secondary text-sm">待我审批</router-link>
|
||||
<router-link v-if="canMaintain" to="/maintenance" class="btn-secondary text-sm">维修管理</router-link>
|
||||
</div>
|
||||
<p class="page-kicker">Role Workspace</p>
|
||||
<h2 class="page-title">{{ roleTitle }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">今日借出</p>
|
||||
<p class="page-meta__value">{{ formatNumber(stats?.today_borrow || 0) }}</p>
|
||||
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_borrow_value || 0) }}</p>
|
||||
</div>
|
||||
<div v-if="canApproveDisposals" class="page-meta">
|
||||
<p class="page-meta__label">待我审批</p>
|
||||
<p class="page-meta__value text-status-warning">{{ pendingDisposals }}</p>
|
||||
<p class="page-meta__subvalue">报废申请</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">今日归还</p>
|
||||
<p class="page-meta__value">{{ formatNumber(stats?.today_return || 0) }}</p>
|
||||
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_return_value || 0) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">利用率</p>
|
||||
<p class="page-meta__value">{{ formatPercent(stats?.usage_rate || 0) }}</p>
|
||||
<p class="page-meta__subvalue">价值 {{ formatPercent(stats?.value_usage_rate || 0) }}</p>
|
||||
<div v-for="item in dashboardMetaCards" :key="item.label" class="page-meta">
|
||||
<p class="page-meta__label">{{ item.label }}</p>
|
||||
<p class="page-meta__value" :class="item.valueClass">{{ item.value }}</p>
|
||||
<p class="page-meta__subvalue">{{ item.subvalue }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<div>
|
||||
<h3 class="section-title">{{ roleActionHeading }}</h3>
|
||||
<p class="mt-1 text-xs text-text-tertiary">按当前角色展示最常用的工作入口</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<router-link
|
||||
v-for="action in roleActions"
|
||||
:key="action.id"
|
||||
:to="action.to"
|
||||
class="panel group flex min-h-[112px] flex-col justify-between transition hover:border-border-hover hover:bg-bg-hover"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-text-primary">{{ action.label }}</p>
|
||||
<span
|
||||
v-if="roleActionStates[action.id]"
|
||||
class="badge"
|
||||
:class="roleActionStates[action.id].badgeClass"
|
||||
>
|
||||
{{ roleActionStates[action.id].label }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 text-xs leading-5 text-text-secondary">{{ action.description }}</p>
|
||||
</router-link>
|
||||
</div>
|
||||
<p v-if="isAdminRole && deliveryStatusError" class="mt-3 text-xs text-status-error">
|
||||
通知投递状态暂时读取失败,可进入系统设置重试。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid gap-2 sm:gap-3 grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="card in statCards.slice(0, 4)" :key="card.title" class="card">
|
||||
@ -310,7 +320,9 @@ import { getBorrowRecordList } from '@/api/borrow'
|
||||
import type { DashboardStats, BorrowRecord } from '@/types'
|
||||
import { getMyAssets, type MyAssetRecord } from '@/api/myAssets'
|
||||
import { getDisposalList } from '@/api/disposal'
|
||||
import { getDingTalkDeliveryStatus } from '@/api/dingtalk'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
import { getDashboardRoleActions, getDashboardRoleTitle } from './dashboardRoleConfig'
|
||||
import { formatCurrencyNoFraction, formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
||||
import {
|
||||
BanknotesIcon,
|
||||
@ -328,6 +340,8 @@ const statusChartRef = ref<HTMLElement>()
|
||||
const categoryChartRef = ref<HTMLElement>()
|
||||
const isLoading = ref(false)
|
||||
const pendingDisposals = ref(0)
|
||||
const failedDeliveries = ref(0)
|
||||
const deliveryStatusError = ref(false)
|
||||
const myAssets = ref<MyAssetRecord[]>([])
|
||||
const myAssetsLoading = ref(false)
|
||||
const myAssetsError = ref('')
|
||||
@ -335,10 +349,58 @@ let statusChart: echarts.ECharts | null = null
|
||||
let categoryChart: echarts.ECharts | null = null
|
||||
|
||||
const canViewReports = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
|
||||
const canViewAllAssets = computed(() => authStore.can(PERMISSIONS.ASSETS_VIEW_ALL))
|
||||
const canBorrow = computed(() => authStore.can(PERMISSIONS.ASSETS_BORROW))
|
||||
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
|
||||
const canApproveDisposals = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE))
|
||||
const currentRole = computed(() => authStore.user?.role || 'user')
|
||||
const isAdminRole = computed(() => currentRole.value === 'admin')
|
||||
const roleTitle = computed(() => getDashboardRoleTitle(currentRole.value))
|
||||
const roleActions = computed(() => getDashboardRoleActions(currentRole.value))
|
||||
const roleActionHeading = computed(() => {
|
||||
if (currentRole.value === 'administrative') return '行政常用工作'
|
||||
if (currentRole.value === 'it') return 'IT 常用工作'
|
||||
return '管理入口'
|
||||
})
|
||||
|
||||
const roleActionStates = computed<Record<string, { label: string; badgeClass: string }>>(() => ({
|
||||
'disposal-approval': {
|
||||
label: `${pendingDisposals.value} 待审批`,
|
||||
badgeClass: pendingDisposals.value > 0 ? 'badge-warning' : 'badge-neutral'
|
||||
},
|
||||
maintenance: {
|
||||
label: `${formatNumber(stats.value?.maintenance_equipment || 0)} 维修中`,
|
||||
badgeClass: Number(stats.value?.maintenance_equipment || 0) > 0 ? 'badge-warning' : 'badge-neutral'
|
||||
},
|
||||
delivery: {
|
||||
label: `${failedDeliveries.value} 失败`,
|
||||
badgeClass: failedDeliveries.value > 0 ? 'badge-error' : 'badge-success'
|
||||
}
|
||||
}))
|
||||
|
||||
const dashboardMetaCards = computed(() => {
|
||||
const data = stats.value
|
||||
if (currentRole.value === 'administrative') {
|
||||
return [
|
||||
{ label: '待我审批', value: formatNumber(pendingDisposals.value), subvalue: '报废申请', valueClass: pendingDisposals.value > 0 ? 'text-status-warning' : '' },
|
||||
{ label: '库存总量', value: formatNumber(data?.total_equipment || 0), subvalue: '资产记录', valueClass: '' },
|
||||
{ label: '可用库存', value: formatNumber(data?.available_equipment || 0), subvalue: '可入库分配', valueClass: 'text-status-success' },
|
||||
{ label: '今日归还', value: formatNumber(data?.today_return || 0), subvalue: formatCurrencyNoFraction(data?.today_return_value || 0), valueClass: '' }
|
||||
]
|
||||
}
|
||||
if (currentRole.value === 'it') {
|
||||
return [
|
||||
{ label: '今日借出', value: formatNumber(data?.today_borrow || 0), subvalue: formatCurrencyNoFraction(data?.today_borrow_value || 0), valueClass: '' },
|
||||
{ label: '今日归还', value: formatNumber(data?.today_return || 0), subvalue: formatCurrencyNoFraction(data?.today_return_value || 0), valueClass: '' },
|
||||
{ label: '维修中', value: formatNumber(data?.maintenance_equipment || 0), subvalue: '待 IT 跟进', valueClass: Number(data?.maintenance_equipment || 0) > 0 ? 'text-status-warning' : '' },
|
||||
{ label: '已借出', value: formatNumber(data?.borrowed_equipment || 0), subvalue: `利用率 ${formatPercent(data?.usage_rate || 0)}`, valueClass: '' }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ label: '库存总量', value: formatNumber(data?.total_equipment || 0), subvalue: formatCurrencyNoFraction(data?.total_value || 0), valueClass: '' },
|
||||
{ label: '待审批', value: formatNumber(pendingDisposals.value), subvalue: '报废申请', valueClass: pendingDisposals.value > 0 ? 'text-status-warning' : '' },
|
||||
{ label: '失败投递', value: formatNumber(failedDeliveries.value), subvalue: deliveryStatusError.value ? '状态读取失败' : '钉钉通知', valueClass: failedDeliveries.value > 0 ? 'text-status-error' : 'text-status-success' },
|
||||
{ label: '利用率', value: formatPercent(data?.usage_rate || 0), subvalue: `价值 ${formatPercent(data?.value_usage_rate || 0)}`, valueClass: '' }
|
||||
]
|
||||
})
|
||||
|
||||
const statCards = computed(() => {
|
||||
const data = stats.value
|
||||
@ -422,11 +484,22 @@ const fetchRoleSummary = async () => {
|
||||
if (canApproveDisposals.value) {
|
||||
try {
|
||||
const response = await getDisposalList({ page: 1, pageSize: 1, scope: 'pending' })
|
||||
pendingDisposals.value = response.data?.total || 0
|
||||
pendingDisposals.value = response.data?.summary?.pending || response.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取待审批数量失败:', error)
|
||||
}
|
||||
}
|
||||
if (isAdminRole.value) {
|
||||
deliveryStatusError.value = false
|
||||
try {
|
||||
const response = await getDingTalkDeliveryStatus()
|
||||
failedDeliveries.value = response.data?.failed || 0
|
||||
} catch (error) {
|
||||
console.error('获取通知投递状态失败:', error)
|
||||
deliveryStatusError.value = true
|
||||
failedDeliveries.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyAssets = async () => {
|
||||
|
||||
42
frontend/src/views/dashboard/dashboardRoleConfig.ts
Normal file
42
frontend/src/views/dashboard/dashboardRoleConfig.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { UserRole } from '@/types'
|
||||
|
||||
export interface DashboardRoleAction {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const ACTIONS_BY_ROLE: Record<UserRole, DashboardRoleAction[]> = {
|
||||
administrative: [
|
||||
{ id: 'asset-create', label: '资产入库', description: '登记单台资产和基础资料', to: '/equipment/add' },
|
||||
{ id: 'asset-import', label: '批量导入', description: '在资产管理中导入入库表格', to: '/equipment' },
|
||||
{ id: 'inventory', label: '盘点任务', description: '创建任务并跟进盘点结果', to: '/inventory' },
|
||||
{ id: 'disposal-approval', label: '报废审批', description: '处理 IT 发起的报废申请', to: '/workflows/disposals?scope=pending' }
|
||||
],
|
||||
it: [
|
||||
{ id: 'borrow', label: '资产借出', description: '扫码选择使用者并完成出库', to: '/borrow/scan' },
|
||||
{ id: 'return', label: '资产归还', description: '扫码归还资产及随借配件', to: '/return/scan' },
|
||||
{ id: 'transfer', label: '调拨与闲置', description: '在资产管理中选择资产后调拨', to: '/equipment' },
|
||||
{ id: 'maintenance', label: '维修管理', description: '登记、完成或取消维修', to: '/maintenance' },
|
||||
{ id: 'disposal-apply', label: '发起报废', description: '在资产详情中选择整机及配件', to: '/equipment' }
|
||||
],
|
||||
admin: [
|
||||
{ id: 'system', label: '系统设置', description: '管理用户、角色、同步和系统配置', to: '/settings' },
|
||||
{ id: 'users', label: '用户与员工绑定', description: '维护账号角色和钉钉免登绑定', to: '/settings?tab=users' },
|
||||
{ id: 'delivery', label: '通知投递', description: '检查失败投递和通知通道状态', to: '/settings?tab=dingtalk' },
|
||||
{ id: 'assets', label: '资产管理', description: '进入完整资产管理和应急处理', to: '/equipment' }
|
||||
],
|
||||
user: [
|
||||
{ id: 'my-assets', label: '我的资产', description: '查看本人当前未归还资产', to: '/my-assets' }
|
||||
]
|
||||
}
|
||||
|
||||
export const getDashboardRoleActions = (role: UserRole) => ACTIONS_BY_ROLE[role]
|
||||
|
||||
export const getDashboardRoleTitle = (role: UserRole) => {
|
||||
if (role === 'administrative') return '行政工作台'
|
||||
if (role === 'it') return 'IT 工作台'
|
||||
if (role === 'admin') return '管理总览'
|
||||
return '我的资产'
|
||||
}
|
||||
@ -540,7 +540,7 @@
|
||||
正在搜索使用者...
|
||||
</p>
|
||||
<p v-else class="py-4 text-center text-sm text-text-tertiary">
|
||||
当前没有匹配到使用者,请换个关键词再试。
|
||||
当前没有匹配到使用者,请换个关键词再试;仍未找到时请联系管理员维护花名册。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -3,19 +3,21 @@
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Inbox</p>
|
||||
<h2 class="page-title">站内消息</h2>
|
||||
<p class="page-kicker">System Records</p>
|
||||
<h2 class="page-title">系统记录</h2>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="page-meta" style="min-width: 120px;">
|
||||
<p class="page-meta__label">未读消息</p>
|
||||
<p class="page-meta__value">{{ notificationStore.unreadCount }}</p>
|
||||
<p class="page-meta__subvalue">待查看</p>
|
||||
<p class="page-meta__label">记录总数</p>
|
||||
<p class="page-meta__value">{{ total }}</p>
|
||||
<p class="page-meta__subvalue">系统留痕</p>
|
||||
</div>
|
||||
<div class="page-meta" style="min-width: 120px;">
|
||||
<p class="page-meta__label">当前筛选</p>
|
||||
<p class="page-meta__value">{{ statusFilter === 'unread' ? '仅未读' : '全部' }}</p>
|
||||
<p class="page-meta__subvalue">自动沉淀</p>
|
||||
<p class="page-meta__label">待处理</p>
|
||||
<p class="page-meta__value" :class="notificationStore.unreadCount > 0 ? 'text-status-warning' : ''">
|
||||
{{ notificationStore.unreadCount }}
|
||||
</p>
|
||||
<p class="page-meta__subvalue">尚未查看记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -25,7 +27,7 @@
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__inputs">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">消息范围</label>
|
||||
<label class="field-label">记录范围</label>
|
||||
<div class="segmented-toggle">
|
||||
<button
|
||||
type="button"
|
||||
@ -33,7 +35,7 @@
|
||||
:class="{ 'is-active': statusFilter === 'all' }"
|
||||
@click="switchFilter('all')"
|
||||
>
|
||||
全部消息
|
||||
全部记录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -41,7 +43,7 @@
|
||||
:class="{ 'is-active': statusFilter === 'unread' }"
|
||||
@click="switchFilter('unread')"
|
||||
>
|
||||
仅看未读
|
||||
尚未查看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -52,11 +54,11 @@
|
||||
{{ loading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary text-xs sm:text-sm"
|
||||
class="btn-secondary text-xs sm:text-sm"
|
||||
:disabled="markAllLoading || notificationStore.unreadCount === 0"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
{{ markAllLoading ? '处理中...' : '全标已读' }}
|
||||
{{ markAllLoading ? '处理中...' : '全部标为已查看' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,24 +70,16 @@
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
:class="item.is_read ? 'border-border' : 'border-accent-primary/30'"
|
||||
:class="isNotificationAttentionRequired(item) ? 'border-status-warning/40' : 'border-border'"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="badge"
|
||||
:class="item.is_read ? 'badge-neutral' : 'badge-success'"
|
||||
>
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</span>
|
||||
<span
|
||||
class="badge"
|
||||
:class="getLevelBadgeClass(item.level)"
|
||||
>
|
||||
{{ getLevelLabel(item.level) }}
|
||||
<span class="badge" :class="getNotificationRecordBadge(item).badgeClass">
|
||||
{{ getNotificationRecordBadge(item).label }}
|
||||
</span>
|
||||
<span class="text-xs text-text-tertiary">{{ formatDateTime(item.notification_created_at) }}</span>
|
||||
<span v-if="!item.is_read" class="text-xs text-text-tertiary">尚未查看</span>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-3 text-lg font-semibold text-text-primary">{{ item.title }}</h3>
|
||||
@ -107,27 +101,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col">
|
||||
<div class="hidden shrink-0 flex-wrap gap-3 md:flex lg:w-[220px] lg:flex-col">
|
||||
<button
|
||||
v-if="!item.is_read"
|
||||
class="btn-secondary w-full text-sm"
|
||||
@click="handleMarkRead(item)"
|
||||
>
|
||||
标记已读
|
||||
标为已查看
|
||||
</button>
|
||||
<button
|
||||
v-if="item.action_url || item.related_sync_job_id"
|
||||
class="btn-primary w-full text-sm"
|
||||
@click="openAction(item)"
|
||||
>
|
||||
{{ item.related_sync_job_id ? '查看同步详情' : '查看同步页' }}
|
||||
{{ getNotificationActionLabel(item) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 md:hidden">
|
||||
<button
|
||||
v-if="item.action_url || item.related_sync_job_id"
|
||||
class="btn-primary flex-1"
|
||||
@click="openAction(item)"
|
||||
>
|
||||
{{ getNotificationActionLabel(item) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!item.is_read && !(item.action_url || item.related_sync_job_id)"
|
||||
class="btn-secondary flex-1"
|
||||
@click="handleMarkRead(item)"
|
||||
>
|
||||
标为已查看
|
||||
</button>
|
||||
<button
|
||||
v-if="!item.is_read && (item.action_url || item.related_sync_job_id)"
|
||||
class="btn-secondary"
|
||||
@click="mobileActionNotification = item"
|
||||
>
|
||||
更多操作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="!loading && notifications.length === 0" class="empty-state">
|
||||
当前没有{{ statusFilter === 'unread' ? '未读' : '' }}站内消息。
|
||||
当前没有{{ statusFilter === 'unread' ? '尚未查看的' : '' }}系统记录。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -135,7 +153,7 @@
|
||||
<section v-if="total > 0" class="card">
|
||||
<div class="pagination-bar">
|
||||
<p class="text-sm text-text-tertiary">
|
||||
第 {{ currentPage }} / {{ totalPages }} 页,共 {{ total }} 条消息
|
||||
第 {{ currentPage }} / {{ totalPages }} 页,共 {{ total }} 条系统记录
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn-secondary text-sm" :disabled="currentPage === 1 || loading" @click="handlePrevPage">
|
||||
@ -148,6 +166,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MobileActionSheet
|
||||
:open="Boolean(mobileActionNotification)"
|
||||
title="记录操作"
|
||||
:description="mobileActionNotification?.title || ''"
|
||||
@close="mobileActionNotification = null"
|
||||
>
|
||||
<button type="button" class="btn-secondary w-full" @click="handleMobileMarkRead">标为已查看</button>
|
||||
</MobileActionSheet>
|
||||
|
||||
<div v-if="syncDetailOpen" class="sheet-backdrop" @click.self="closeSyncDetail">
|
||||
<div class="sheet-panel max-w-5xl">
|
||||
<div class="form-panel__header">
|
||||
@ -422,7 +449,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-lg border border-dashed border-border bg-bg-primary/20 px-4 py-6 text-sm leading-6 text-text-tertiary">
|
||||
点左侧的资产胶囊后,这里会直接出现该资产的快捷入库面板。处理完当前这件后,你可以继续点下一件,不需要离开站内消息。
|
||||
点左侧的资产胶囊后,这里会直接出现该资产的快捷入库面板。处理完当前这件后,你可以继续点下一件,不需要离开系统记录。
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@ -472,6 +499,12 @@ import {
|
||||
buildNotificationQuickReturnAssetKey,
|
||||
findNotificationQuickReturnRecord
|
||||
} from './notificationQuickReturn'
|
||||
import {
|
||||
getNotificationActionLabel,
|
||||
getNotificationRecordBadge,
|
||||
isNotificationAttentionRequired
|
||||
} from './notificationPresentation'
|
||||
import MobileActionSheet from '@/components/MobileActionSheet.vue'
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
const authStore = useAuthStore()
|
||||
@ -484,6 +517,7 @@ const pageSize = 12
|
||||
const statusFilter = ref<'all' | 'unread'>('all')
|
||||
const loading = ref(false)
|
||||
const markAllLoading = ref(false)
|
||||
const mobileActionNotification = ref<NotificationItem | null>(null)
|
||||
const syncDetailOpen = ref(false)
|
||||
const syncDetailLoading = ref(false)
|
||||
const selectedSyncJob = ref<HrSyncJobDetail | null>(null)
|
||||
@ -566,18 +600,6 @@ const canSubmitQuickReturn = computed(() =>
|
||||
})
|
||||
)
|
||||
|
||||
const getLevelLabel = (level?: string) => {
|
||||
if (level === 'error') return '异常'
|
||||
if (level === 'warning') return '提醒'
|
||||
return '信息'
|
||||
}
|
||||
|
||||
const getLevelBadgeClass = (level?: string) => {
|
||||
if (level === 'error') return 'badge-error'
|
||||
if (level === 'warning') return 'badge-warning'
|
||||
return 'badge-neutral'
|
||||
}
|
||||
|
||||
const normalizeChangeProcessingStatus = (
|
||||
status?: HrSyncChangeProcessingStatus | string | null
|
||||
): HrSyncChangeProcessingStatus => {
|
||||
@ -714,7 +736,7 @@ const fetchNotifications = async () => {
|
||||
notificationStore.setUnreadCount(response.data.unread_count || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '获取站内消息失败')
|
||||
alert(error?.message || '获取系统记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -736,7 +758,7 @@ const handleMarkRead = async (item: NotificationItem) => {
|
||||
fetchNotifications()
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '标记已读失败')
|
||||
alert(error?.message || '标为已查看失败')
|
||||
}
|
||||
}
|
||||
|
||||
@ -747,12 +769,18 @@ const handleMarkAllRead = async () => {
|
||||
await notificationStore.fetchUnreadCount()
|
||||
await fetchNotifications()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '全部标记已读失败')
|
||||
alert(error?.message || '全部标为已查看失败')
|
||||
} finally {
|
||||
markAllLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMobileMarkRead = async () => {
|
||||
const item = mobileActionNotification.value
|
||||
mobileActionNotification.value = null
|
||||
if (item) await handleMarkRead(item)
|
||||
}
|
||||
|
||||
const closeSyncDetail = () => {
|
||||
syncDetailOpen.value = false
|
||||
selectedSyncJob.value = null
|
||||
|
||||
18
frontend/src/views/notifications/notificationPresentation.ts
Normal file
18
frontend/src/views/notifications/notificationPresentation.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { NotificationItem } from '@/types'
|
||||
|
||||
export const isNotificationAttentionRequired = (item: Pick<NotificationItem, 'level'>) =>
|
||||
item.level === 'error' || item.level === 'warning'
|
||||
|
||||
export const getNotificationRecordBadge = (item: Pick<NotificationItem, 'level'>) => {
|
||||
if (item.level === 'error') return { label: '异常待处理', badgeClass: 'badge-error' }
|
||||
if (item.level === 'warning') return { label: '需要关注', badgeClass: 'badge-warning' }
|
||||
return { label: '系统记录', badgeClass: 'badge-neutral' }
|
||||
}
|
||||
|
||||
export const getNotificationActionLabel = (
|
||||
item: Pick<NotificationItem, 'level' | 'related_sync_job_id' | 'action_url'>
|
||||
) => {
|
||||
if (item.related_sync_job_id) return '查看同步详情'
|
||||
if (isNotificationAttentionRequired(item)) return '前往处理'
|
||||
return item.action_url ? '查看相关记录' : '查看记录'
|
||||
}
|
||||
@ -7,21 +7,25 @@
|
||||
<h2 class="page-title">报废流程</h2>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">总记录</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.total) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">待审批</p>
|
||||
<p class="page-meta__value">{{ pageSummary.pending }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.pending) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已审批</p>
|
||||
<p class="page-meta__value">{{ pageSummary.approved }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.approved) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已完成</p>
|
||||
<p class="page-meta__value">{{ pageSummary.completed }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.completed) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已驳回</p>
|
||||
<p class="page-meta__value">{{ pageSummary.rejected }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.rejected) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -82,10 +86,21 @@
|
||||
<!-- 数据列表 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">报废记录</h3>
|
||||
<div>
|
||||
<h3 class="section-title">报废记录</h3>
|
||||
<p class="mt-1 text-xs text-text-tertiary">当前范围共 {{ summary?.total || 0 }} 条,筛选结果 {{ total }} 条</p>
|
||||
</div>
|
||||
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="loadList">刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty-state">正在加载报废记录...</div>
|
||||
<div v-else-if="listError" class="panel flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm text-status-error">{{ listError }}</p>
|
||||
<button class="btn-secondary" @click="loadList">重新加载</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- 移动端卡片 -->
|
||||
<div class="mobile-card-list md:hidden">
|
||||
<article v-for="item in list" :key="`mobile-${item.id}`" class="mobile-card">
|
||||
@ -128,8 +143,7 @@
|
||||
<p class="mobile-card__value">{{ item.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn-secondary text-xs sm:text-sm" @click="openDetail(item)">查看详情</button>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
v-if="item.status === 'pending' && canApprove"
|
||||
class="btn-primary text-xs sm:text-sm"
|
||||
@ -138,19 +152,20 @@
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'pending' && canApprove"
|
||||
class="btn-secondary text-xs sm:text-sm !text-status-error"
|
||||
@click="handleApprove(item, false)"
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'approved' && canComplete"
|
||||
class="btn-secondary text-xs sm:text-sm"
|
||||
v-else-if="item.status === 'approved' && canComplete"
|
||||
class="btn-primary text-xs sm:text-sm"
|
||||
@click="handleComplete(item)"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
<button v-else class="btn-secondary text-xs sm:text-sm" @click="openDetail(item)">查看详情</button>
|
||||
<button
|
||||
v-if="hasMobileSecondaryActions(item)"
|
||||
class="btn-secondary text-xs sm:text-sm"
|
||||
@click="mobileActionItem = item"
|
||||
>
|
||||
更多操作
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@ -240,8 +255,26 @@
|
||||
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<MobileActionSheet
|
||||
:open="Boolean(mobileActionItem)"
|
||||
title="报废流程操作"
|
||||
:description="mobileActionItem ? `${mobileActionItem.equipment_code} · ${mobileActionItem.equipment?.equipment_name || '资产'}` : ''"
|
||||
@close="mobileActionItem = null"
|
||||
>
|
||||
<button type="button" class="btn-secondary w-full" @click="handleMobileOpenDetail">查看详情</button>
|
||||
<button
|
||||
v-if="mobileActionItem?.status === 'pending' && canApprove"
|
||||
type="button"
|
||||
class="btn-secondary w-full !text-status-error"
|
||||
@click="handleMobileReject"
|
||||
>
|
||||
驳回申请
|
||||
</button>
|
||||
</MobileActionSheet>
|
||||
|
||||
<!-- 完成处置弹窗 -->
|
||||
<div v-if="showCompleteModal" class="sheet-backdrop">
|
||||
<div class="sheet-panel max-w-2xl">
|
||||
@ -283,11 +316,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getDisposalList, approveDisposal, completeDisposal, type DisposalRecord } from '@/api/disposal'
|
||||
import {
|
||||
getDisposalList,
|
||||
approveDisposal,
|
||||
completeDisposal,
|
||||
type DisposalRecord,
|
||||
type DisposalSummary
|
||||
} from '@/api/disposal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
import MobileActionSheet from '@/components/MobileActionSheet.vue'
|
||||
|
||||
type DisposalScope = 'pending' | 'mine' | 'processed' | 'all'
|
||||
|
||||
@ -311,31 +351,33 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const loading = ref(false)
|
||||
const listError = ref('')
|
||||
const summary = ref<DisposalSummary | null>(null)
|
||||
const filters = ref({ status: '', keyword: '' })
|
||||
const showCompleteModal = ref(false)
|
||||
const currentItem = ref<DisposalRecord | null>(null)
|
||||
const completeForm = ref({ actual_value: 0, completion_notes: '' })
|
||||
const submittingComplete = ref(false)
|
||||
const activeScope = ref<DisposalScope>('pending')
|
||||
const mobileActionItem = ref<DisposalRecord | null>(null)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
const pageSummary = computed(() => ({
|
||||
pending: list.value.filter((item) => item.status === 'pending').length,
|
||||
approved: list.value.filter((item) => item.status === 'approved').length,
|
||||
completed: list.value.filter((item) => item.status === 'completed').length,
|
||||
rejected: list.value.filter((item) => item.status === 'rejected').length
|
||||
}))
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
listError.value = ''
|
||||
try {
|
||||
const res = await getDisposalList({ page: page.value, pageSize: pageSize.value, scope: activeScope.value, ...filters.value })
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e) {
|
||||
summary.value = res.data?.summary || null
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
summary.value = null
|
||||
listError.value = e?.message || '报废记录加载失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -369,6 +411,21 @@ const changeScope = (scope: DisposalScope) => {
|
||||
|
||||
const openDetail = (item: DisposalRecord) => router.push(`/workflows/disposals/${item.id}`)
|
||||
|
||||
const hasMobileSecondaryActions = (item: DisposalRecord) =>
|
||||
(item.status === 'pending' && canApprove.value) || (item.status === 'approved' && canComplete.value)
|
||||
|
||||
const handleMobileOpenDetail = () => {
|
||||
const item = mobileActionItem.value
|
||||
mobileActionItem.value = null
|
||||
if (item) openDetail(item)
|
||||
}
|
||||
|
||||
const handleMobileReject = async () => {
|
||||
const item = mobileActionItem.value
|
||||
mobileActionItem.value = null
|
||||
if (item) await handleApprove(item, false)
|
||||
}
|
||||
|
||||
const handleApprove = async (item: DisposalRecord, approved: boolean) => {
|
||||
const msg = approved ? '确定通过此报废申请吗?' : '确定驳回此报废申请吗?'
|
||||
if (!confirm(msg)) return
|
||||
@ -445,6 +502,7 @@ const formatDate = (date: string) => {
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => `¥${Number(value || 0).toFixed(2)}`
|
||||
const formatSummaryCount = (value?: number) => loading.value && summary.value === null ? '—' : String(value || 0)
|
||||
|
||||
onMounted(() => {
|
||||
const requestedScope = route.query.scope as DisposalScope | undefined
|
||||
@ -454,4 +512,8 @@ onMounted(() => {
|
||||
: allowedScopes[0] || 'mine'
|
||||
loadList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -7,17 +7,21 @@
|
||||
<h2 class="page-title">维修管理</h2>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">总记录</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.total) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">维修中</p>
|
||||
<p class="page-meta__value">{{ pageSummary.inProgress }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.in_progress) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已完成</p>
|
||||
<p class="page-meta__value">{{ pageSummary.completed }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.completed) }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已取消</p>
|
||||
<p class="page-meta__value">{{ pageSummary.cancelled }}</p>
|
||||
<p class="page-meta__value">{{ formatSummaryCount(summary?.cancelled) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -59,10 +63,21 @@
|
||||
<!-- 数据列表 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">维修记录</h3>
|
||||
<div>
|
||||
<h3 class="section-title">维修记录</h3>
|
||||
<p class="mt-1 text-xs text-text-tertiary">全部 {{ summary?.total || 0 }} 条,筛选结果 {{ total }} 条</p>
|
||||
</div>
|
||||
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="loadList">刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty-state">正在加载维修记录...</div>
|
||||
<div v-else-if="listError" class="panel flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm text-status-error">{{ listError }}</p>
|
||||
<button class="btn-secondary" @click="loadList">重新加载</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- 移动端卡片 -->
|
||||
<div class="mobile-card-list md:hidden">
|
||||
<article v-for="item in list" :key="`mobile-${item.id}`" class="mobile-card">
|
||||
@ -105,9 +120,9 @@
|
||||
<p class="mobile-card__value">{{ item.repair_result }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="item.status === 'in_progress'" class="mt-4 flex flex-wrap gap-2">
|
||||
<div v-if="item.status === 'in_progress'" class="mt-4 flex gap-2">
|
||||
<button class="btn-primary text-xs sm:text-sm" @click="handleComplete(item)">完成</button>
|
||||
<button class="btn-secondary text-xs sm:text-sm !text-status-error" @click="handleCancel(item)">取消</button>
|
||||
<button class="btn-secondary text-xs sm:text-sm" @click="mobileActionItem = item">更多操作</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@ -188,8 +203,20 @@
|
||||
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<MobileActionSheet
|
||||
:open="Boolean(mobileActionItem)"
|
||||
title="维修记录操作"
|
||||
:description="mobileActionItem ? `${mobileActionItem.equipment_code} · ${mobileActionItem.equipment?.equipment_name || '资产'}` : ''"
|
||||
@close="mobileActionItem = null"
|
||||
>
|
||||
<button type="button" class="btn-secondary w-full !text-status-error" @click="handleMobileCancel">
|
||||
取消维修
|
||||
</button>
|
||||
</MobileActionSheet>
|
||||
|
||||
<!-- 完成维修弹窗 -->
|
||||
<div v-if="showCompleteModal" class="sheet-backdrop">
|
||||
<div class="sheet-panel max-w-2xl">
|
||||
@ -231,37 +258,47 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getMaintenanceList, completeMaintenance, cancelMaintenance, type MaintenanceRecord } from '@/api/maintenance'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
getMaintenanceList,
|
||||
completeMaintenance,
|
||||
cancelMaintenance,
|
||||
type MaintenanceRecord,
|
||||
type MaintenanceSummary
|
||||
} from '@/api/maintenance'
|
||||
import MobileActionSheet from '@/components/MobileActionSheet.vue'
|
||||
|
||||
const list = ref<MaintenanceRecord[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const loading = ref(false)
|
||||
const listError = ref('')
|
||||
const summary = ref<MaintenanceSummary | null>(null)
|
||||
const filters = ref({ status: '', keyword: '' })
|
||||
const showCompleteModal = ref(false)
|
||||
const currentItem = ref<MaintenanceRecord | null>(null)
|
||||
const completeForm = ref({ actual_cost: 0, repair_result: '' })
|
||||
const submittingComplete = ref(false)
|
||||
const mobileActionItem = ref<MaintenanceRecord | null>(null)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
const pageSummary = computed(() => ({
|
||||
inProgress: list.value.filter((item) => item.status === 'in_progress').length,
|
||||
completed: list.value.filter((item) => item.status === 'completed').length,
|
||||
cancelled: list.value.filter((item) => item.status === 'cancelled').length
|
||||
}))
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
listError.value = ''
|
||||
try {
|
||||
const res = await getMaintenanceList({ page: page.value, pageSize: pageSize.value, ...filters.value })
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e) {
|
||||
summary.value = res.data?.summary || null
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
summary.value = null
|
||||
listError.value = e?.message || '维修记录加载失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -299,8 +336,10 @@ const submitComplete = async () => {
|
||||
await completeMaintenance(currentItem.value.id, completeForm.value)
|
||||
showCompleteModal.value = false
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert(e?.message || '完成维修失败,请刷新后重试。')
|
||||
await loadList()
|
||||
} finally {
|
||||
submittingComplete.value = false
|
||||
}
|
||||
@ -312,11 +351,19 @@ const handleCancel = async (item: MaintenanceRecord) => {
|
||||
const reason = window.prompt('如需备注取消原因,可以在这里填写。', '手动取消') || '手动取消'
|
||||
await cancelMaintenance(item.id, { reason })
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert(e?.message || '取消维修失败,请刷新后重试。')
|
||||
await loadList()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMobileCancel = async () => {
|
||||
const item = mobileActionItem.value
|
||||
mobileActionItem.value = null
|
||||
if (item) await handleCancel(item)
|
||||
}
|
||||
|
||||
const handlePrevPage = async () => {
|
||||
if (page.value <= 1) return
|
||||
page.value -= 1
|
||||
@ -349,6 +396,11 @@ const formatDate = (date: string) => {
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => `¥${Number(value || 0).toFixed(2)}`
|
||||
const formatSummaryCount = (value?: number) => loading.value && summary.value === null ? '—' : String(value || 0)
|
||||
|
||||
onMounted(loadList)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,9 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="card">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__inputs">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">搜索用户</label>
|
||||
<input
|
||||
v-model.trim="filters.keyword"
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder="用户名、姓名、邮箱或手机号"
|
||||
@input="handleUserSearch"
|
||||
@keyup.enter="applyUserFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">角色</label>
|
||||
<select v-model="filters.role" class="select" @change="applyUserFilters">
|
||||
<option value="">全部角色</option>
|
||||
<option value="administrative">行政</option>
|
||||
<option value="it">IT</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="user">普通用户</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">状态</label>
|
||||
<select v-model="filters.status" class="select" @change="applyUserFilters">
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">启用</option>
|
||||
<option value="inactive">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar__actions">
|
||||
<button class="btn-secondary" :disabled="listLoading" @click="resetUserFilters">重置</button>
|
||||
<button class="btn-primary" :disabled="listLoading" @click="applyUserFilters">
|
||||
{{ listLoading ? '查询中...' : '查询' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 列表 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">用户列表</h3>
|
||||
<div>
|
||||
<h3 class="section-title">用户列表</h3>
|
||||
<p class="mt-1 text-xs text-text-tertiary">共 {{ total }} 个账号</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button class="btn-secondary text-xs sm:text-sm" :disabled="listLoading" @click="fetchUsers">
|
||||
刷新
|
||||
@ -14,8 +59,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="empty-state">正在加载用户...</div>
|
||||
<div v-else-if="listError" class="panel flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm text-status-error">{{ listError }}</p>
|
||||
<button class="btn-secondary" @click="fetchUsers">重新加载</button>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="mobile-card-list md:hidden">
|
||||
<div v-else class="mobile-card-list md:hidden">
|
||||
<div v-for="user in users" :key="user.id" class="mobile-card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@ -40,22 +91,26 @@
|
||||
<div class="mt-3 text-sm text-text-secondary space-y-1">
|
||||
<p v-if="user.email">{{ user.email }}</p>
|
||||
<p v-if="user.phone">{{ user.phone }}</p>
|
||||
<p>员工绑定: {{ getBoundPersonLabel(user) }}</p>
|
||||
<p>员工绑定:{{ getBoundPersonLabel(user) }}</p>
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
<span class="badge" :class="isUserPersonBound(user) ? 'badge-success' : 'badge-neutral'">
|
||||
{{ isUserPersonBound(user) ? '已绑定员工' : '未绑定员工' }}
|
||||
</span>
|
||||
<span v-if="isUserPersonBound(user)" class="badge" :class="user.dingtalk_ready ? 'badge-success' : 'badge-warning'">
|
||||
{{ user.dingtalk_ready ? '钉钉已就绪' : '钉钉未就绪' }}
|
||||
</span>
|
||||
</div>
|
||||
<p>最近登录: {{ user.last_login_at ? formatDateTime(user.last_login_at) : '-' }}</p>
|
||||
</div>
|
||||
<div class="mt-3 table-action-group">
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="table-action table-action--primary" @click="openEdit(user)">编辑</button>
|
||||
<button class="table-action table-action--warning" @click="handleResetPassword(user)">重置密码</button>
|
||||
<button class="table-action" @click="toggleStatus(user)">
|
||||
{{ user.status === 'active' ? '禁用' : '启用' }}
|
||||
</button>
|
||||
<button class="table-action table-action--danger" @click="handleDeleteUser(user)">删除</button>
|
||||
<button class="table-action" @click="openUserActions(user)">更多操作</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div class="hidden md:block">
|
||||
<div v-if="!listLoading && !listError" class="hidden md:block">
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@ -90,7 +145,17 @@
|
||||
{{ user.status === 'active' ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm text-text-secondary">{{ getBoundPersonLabel(user) }}</td>
|
||||
<td>
|
||||
<p class="text-sm text-text-secondary">{{ getBoundPersonLabel(user) }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span class="badge" :class="isUserPersonBound(user) ? 'badge-success' : 'badge-neutral'">
|
||||
{{ isUserPersonBound(user) ? '已绑定' : '未绑定' }}
|
||||
</span>
|
||||
<span v-if="isUserPersonBound(user)" class="badge" :class="user.dingtalk_ready ? 'badge-success' : 'badge-warning'">
|
||||
{{ user.dingtalk_ready ? '钉钉就绪' : '钉钉未就绪' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm text-text-secondary space-y-1">
|
||||
<p v-if="user.email">{{ user.email }}</p>
|
||||
@ -118,11 +183,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!listLoading && users.length === 0" class="mt-6 text-center text-text-tertiary">
|
||||
暂无用户数据。
|
||||
<p v-if="!listLoading && !listError && users.length === 0" class="empty-state">
|
||||
当前筛选条件下暂无用户。
|
||||
</p>
|
||||
|
||||
<div v-if="!listLoading && !listError && total > 0" class="pagination-bar">
|
||||
<p class="text-sm text-text-secondary">第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 个账号</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn-secondary" :disabled="page <= 1 || listLoading" @click="changeUserPage(page - 1)">上一页</button>
|
||||
<button class="btn-secondary" :disabled="page >= totalPages || listLoading" @click="changeUserPage(page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MobileActionSheet
|
||||
:open="Boolean(mobileActionUser)"
|
||||
title="用户操作"
|
||||
:description="mobileActionUser ? `${mobileActionUser.name}(${mobileActionUser.username})` : ''"
|
||||
@close="mobileActionUser = null"
|
||||
>
|
||||
<button type="button" class="btn-secondary w-full" @click="handleMobileResetPassword">重置密码</button>
|
||||
<button type="button" class="btn-secondary w-full" @click="handleMobileToggleStatus">
|
||||
{{ mobileActionUser?.status === 'active' ? '禁用账号' : '启用账号' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary w-full !text-status-error" @click="handleMobileDeleteUser">删除用户</button>
|
||||
</MobileActionSheet>
|
||||
|
||||
<!-- 新增用户弹窗 -->
|
||||
<div v-if="showCreateUser" class="sheet-backdrop" @click.self="closeCreate">
|
||||
<form
|
||||
@ -259,6 +345,7 @@
|
||||
:key="String(editingUser.id)"
|
||||
v-model="editingUserDraft.person_id"
|
||||
v-model:selected-person="editingUserSelectedPerson"
|
||||
:current-user-id="editingUser.id"
|
||||
clear-label="解除绑定"
|
||||
/>
|
||||
</div>
|
||||
@ -292,7 +379,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import type { User, UserRole } from '@/types'
|
||||
import {
|
||||
@ -301,12 +388,13 @@ import {
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
deleteUser,
|
||||
type UserPersonOption
|
||||
} from '@/api/users'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { PersonOption } from '@/api/person'
|
||||
import { getRoleLabel } from '@/auth/permissions'
|
||||
import PersonBindingPicker from '@/components/PersonBindingPicker.vue'
|
||||
import MobileActionSheet from '@/components/MobileActionSheet.vue'
|
||||
|
||||
const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: string | null })[]>([])
|
||||
const listLoading = ref(false)
|
||||
@ -316,8 +404,21 @@ const showCreateUser = ref(false)
|
||||
const createUserTrigger = ref<HTMLButtonElement | null>(null)
|
||||
const createDialog = ref<HTMLFormElement | null>(null)
|
||||
const createUsernameInput = ref<HTMLInputElement | null>(null)
|
||||
const newUserSelectedPerson = ref<PersonOption | null>(null)
|
||||
const editingUserSelectedPerson = ref<PersonOption | null>(null)
|
||||
const newUserSelectedPerson = ref<UserPersonOption | null>(null)
|
||||
const editingUserSelectedPerson = ref<UserPersonOption | null>(null)
|
||||
const editingOriginalPersonId = ref('')
|
||||
const mobileActionUser = ref<(User & { status: 'active' | 'inactive' }) | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = ref(0)
|
||||
const listError = ref('')
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
role: '',
|
||||
status: ''
|
||||
})
|
||||
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let userRequestSequence = 0
|
||||
|
||||
const newUser = reactive({
|
||||
username: '',
|
||||
@ -344,20 +445,62 @@ const canCreate = computed(() => {
|
||||
return newUser.username.trim() && newUser.name.trim() && newUser.password.length >= 6
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const requestId = ++userRequestSequence
|
||||
listLoading.value = true
|
||||
listError.value = ''
|
||||
try {
|
||||
const response = await getUserList({ page: 1, pageSize: 100 })
|
||||
if (response.data) {
|
||||
users.value = response.data.list as any
|
||||
}
|
||||
} catch (error) {
|
||||
const response = await getUserList({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
keyword: filters.keyword || undefined,
|
||||
role: filters.role || undefined,
|
||||
status: filters.status || undefined
|
||||
})
|
||||
if (!response.data || requestId !== userRequestSequence) return
|
||||
users.value = response.data.list as typeof users.value
|
||||
total.value = response.data.total || 0
|
||||
page.value = response.data.page || page.value
|
||||
} catch (error: any) {
|
||||
if (requestId !== userRequestSequence) return
|
||||
console.error('获取用户列表失败', error)
|
||||
users.value = []
|
||||
total.value = 0
|
||||
listError.value = error?.message || '用户列表加载失败,请稍后重试。'
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
if (requestId === userRequestSequence) listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyUserFilters = () => {
|
||||
if (userSearchTimer) {
|
||||
clearTimeout(userSearchTimer)
|
||||
userSearchTimer = null
|
||||
}
|
||||
page.value = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
const handleUserSearch = () => {
|
||||
if (userSearchTimer) clearTimeout(userSearchTimer)
|
||||
userSearchTimer = setTimeout(applyUserFilters, 300)
|
||||
}
|
||||
|
||||
const resetUserFilters = () => {
|
||||
filters.keyword = ''
|
||||
filters.role = ''
|
||||
filters.status = ''
|
||||
applyUserFilters()
|
||||
}
|
||||
|
||||
const changeUserPage = (nextPage: number) => {
|
||||
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return
|
||||
page.value = nextPage
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
const resetNewForm = () => {
|
||||
newUser.username = ''
|
||||
newUser.name = ''
|
||||
@ -442,13 +585,22 @@ const openEdit = (user: User & { status: 'active' | 'inactive' }) => {
|
||||
editingUserDraft.phone = user.phone || ''
|
||||
editingUserDraft.status = user.status
|
||||
editingUserDraft.person_id = user.person_id ? String(user.person_id) : ''
|
||||
editingOriginalPersonId.value = editingUserDraft.person_id
|
||||
editingUserSelectedPerson.value = user.person
|
||||
? {
|
||||
id: user.person.id,
|
||||
nickname: user.person.nickname,
|
||||
real_name: user.person.real_name,
|
||||
status: user.person.status,
|
||||
dingtalk_ready: user.dingtalk_ready !== false
|
||||
dingtalk_ready: user.dingtalk_ready !== false,
|
||||
is_bound: true,
|
||||
bound_user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status
|
||||
}
|
||||
}
|
||||
: null
|
||||
}
|
||||
@ -456,19 +608,23 @@ const openEdit = (user: User & { status: 'active' | 'inactive' }) => {
|
||||
const closeEdit = () => {
|
||||
editingUser.value = null
|
||||
editingUserSelectedPerson.value = null
|
||||
editingOriginalPersonId.value = ''
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editingUser.value) return
|
||||
try {
|
||||
updateLoading.value = true
|
||||
const personBindingChanged = editingUserDraft.person_id !== editingOriginalPersonId.value
|
||||
await updateUser(editingUser.value.id, {
|
||||
name: editingUserDraft.name,
|
||||
role: editingUserDraft.role,
|
||||
email: editingUserDraft.email || undefined,
|
||||
phone: editingUserDraft.phone || undefined,
|
||||
status: editingUserDraft.status,
|
||||
person_id: editingUserDraft.person_id ? Number(editingUserDraft.person_id) : null
|
||||
...(personBindingChanged
|
||||
? { person_id: editingUserDraft.person_id ? Number(editingUserDraft.person_id) : null }
|
||||
: {})
|
||||
})
|
||||
alert('用户信息已更新')
|
||||
closeEdit()
|
||||
@ -485,7 +641,7 @@ const toggleStatus = async (user: any) => {
|
||||
if (!confirm(`确定要${targetStatus === 'active' ? '启用' : '禁用'}该用户吗?`)) return
|
||||
try {
|
||||
await updateUserStatus(user.id, targetStatus)
|
||||
fetchUsers()
|
||||
await fetchUsers()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '更新状态失败')
|
||||
}
|
||||
@ -511,12 +667,35 @@ const handleDeleteUser = async (user: any) => {
|
||||
try {
|
||||
await deleteUser(user.id)
|
||||
alert('删除成功')
|
||||
fetchUsers()
|
||||
if (users.value.length === 1 && page.value > 1) page.value -= 1
|
||||
await fetchUsers()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openUserActions = (user: User & { status: 'active' | 'inactive' }) => {
|
||||
mobileActionUser.value = user
|
||||
}
|
||||
|
||||
const handleMobileResetPassword = async () => {
|
||||
const user = mobileActionUser.value
|
||||
mobileActionUser.value = null
|
||||
if (user) await handleResetPassword(user)
|
||||
}
|
||||
|
||||
const handleMobileToggleStatus = async () => {
|
||||
const user = mobileActionUser.value
|
||||
mobileActionUser.value = null
|
||||
if (user) await toggleStatus(user)
|
||||
}
|
||||
|
||||
const handleMobileDeleteUser = async () => {
|
||||
const user = mobileActionUser.value
|
||||
mobileActionUser.value = null
|
||||
if (user) await handleDeleteUser(user)
|
||||
}
|
||||
|
||||
const getUserRoleBadgeClass = (role: UserRole) => {
|
||||
const classes: Record<UserRole, string> = {
|
||||
admin: 'badge-warning',
|
||||
@ -540,7 +719,14 @@ const getBoundPersonLabel = (user: User) => {
|
||||
return '未绑定'
|
||||
}
|
||||
|
||||
const isUserPersonBound = (user: User) => Boolean(user.person_bound || user.person_id || user.person)
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (userSearchTimer) clearTimeout(userSearchTimer)
|
||||
userRequestSequence += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user