完善人员同步与设置页并修复中文乱码

This commit is contained in:
WuYongJie 2026-03-11 19:58:44 +08:00
parent 232c350621
commit 65042bf9c9
8 changed files with 1252 additions and 294 deletions

View File

@ -7,6 +7,8 @@
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"seed:demo": "node scripts/seedDemoData.js",
"repair:mojibake": "node scripts/repairMojibake.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio"

View File

@ -0,0 +1,70 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const prisma = new PrismaClient()
const TARGETS = [
{ table: 'site_config', field: 'site_name' },
{ table: '`user`', field: 'name' }
]
const mojibakePattern = '[À-ÿ€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]'
const chinesePattern = '[一-龥]'
const buildFixedExpr = (field) =>
`CONVERT(CAST(CONVERT(${field} USING latin1) AS BINARY) USING utf8mb4) COLLATE utf8mb4_unicode_ci`
async function repairTarget({ table, field }) {
const fixedExpr = buildFixedExpr(field)
const countSql = `
SELECT COUNT(*) AS count
FROM ${table}
WHERE ${field} REGEXP '${mojibakePattern}'
AND ${fixedExpr} REGEXP '${chinesePattern}'
AND ${field} <> ${fixedExpr}
`
const [countRow] = await prisma.$queryRawUnsafe(countSql)
const count = Number(countRow?.count || 0)
if (!count) {
console.log(`${table}.${field}: 无需修复`)
return 0
}
const updateSql = `
UPDATE ${table}
SET ${field} = ${fixedExpr}
WHERE ${field} REGEXP '${mojibakePattern}'
AND ${fixedExpr} REGEXP '${chinesePattern}'
AND ${field} <> ${fixedExpr}
`
await prisma.$executeRawUnsafe(updateSql)
console.log(`${table}.${field}: 已修复 ${count} 条`)
return count
}
async function main() {
let updated = 0
for (const target of TARGETS) {
updated += await repairTarget(target)
}
console.log(`完成:共修复 ${updated} 条乱码数据`)
}
main()
.catch((error) => {
console.error('修复乱码失败:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,755 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const prisma = new PrismaClient()
const TEST_TAG = '[测试数据]'
const locationSeeds = [
{ key: 'hq', name: '总部机房', code: 'HQ01', description: `${TEST_TAG} 总部核心设备区` },
{ key: 'office', name: '行政办公室', code: 'OF01', description: `${TEST_TAG} 行政办公区` },
{ key: 'service', name: '客服办公区', code: 'CS01', description: `${TEST_TAG} 客服与运营办公区` },
{ key: 'warehouse', name: '仓储维修间', code: 'WH01', description: `${TEST_TAG} 仓储和维修测试区` }
]
const departmentNameMap = {
management: '总经理室',
opsA: '运营中心A',
service: '客服中心',
warehouse: '仓储中心',
finance: '财务部'
}
const personDeptKeywordMap = {
manager: '总经理室',
ops: '运营中心A',
service: '客服中心',
warehouse: '仓储中心',
finance: '财务'
}
const dayMs = 24 * 60 * 60 * 1000
const dateOffset = (days) => new Date(Date.now() + days * dayMs)
const normalizeShortName = (value) => {
const normalized = String(value || '').trim().toUpperCase()
return normalized.replace(/[^A-Z0-9]/g, '')
}
const buildEquipmentCode = (shortName, locationId, sequence) =>
`${normalizeShortName(shortName)}-${String(locationId).padStart(4, '0')}-${String(sequence).padStart(5, '0')}`
const calculateValues = (unitPrice, totalQuantity, remainingQuantity) => ({
total_value: Number(unitPrice) * Number(totalQuantity),
borrowed_value: Number(unitPrice) * (Number(totalQuantity) - Number(remainingQuantity)),
available_value: Number(unitPrice) * Number(remainingQuantity)
})
const ensureLocation = async (seed) => {
const existing =
(await prisma.storageLocation.findFirst({ where: { code: seed.code } })) ||
(await prisma.storageLocation.findFirst({ where: { name: seed.name } }))
if (existing) {
return prisma.storageLocation.update({
where: { id: existing.id },
data: {
name: seed.name,
code: seed.code,
status: 'active',
description: seed.description
}
})
}
return prisma.storageLocation.create({
data: {
name: seed.name,
code: seed.code,
status: 'active',
description: seed.description
}
})
}
const ensureEquipment = async (seed) => {
const values = calculateValues(seed.unit_price, seed.total_quantity, seed.remaining_quantity)
const existing = await prisma.equipment.findUnique({
where: { equipment_code: seed.equipment_code }
})
const data = {
equipment_code: seed.equipment_code,
equipment_name: seed.equipment_name,
equipment_model: seed.equipment_model,
equipment_type: seed.equipment_type,
code_generation_mode: 'manual',
status: seed.status,
total_quantity: seed.total_quantity,
remaining_quantity: seed.remaining_quantity,
unit_price: seed.unit_price,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
total_value_with_children: seed.total_value_with_children || values.total_value,
description: seed.description,
purchase_date: seed.purchase_date,
supplier: seed.supplier,
storage_location_id: seed.storage_location_id,
department_id: seed.department_id,
asset_type_id: seed.asset_type_id,
parent_id: seed.parent_id || null,
mount_status: seed.mount_status || 'none'
}
if (existing) {
return prisma.equipment.update({
where: { id: existing.id },
data
})
}
return prisma.equipment.create({ data })
}
const ensureBorrowRecord = async (seed) => {
const existing = await prisma.borrowRecord.findFirst({
where: {
equipment_code: seed.equipment_code,
person_id: seed.person_id,
status: seed.status,
notes: { contains: TEST_TAG }
}
})
if (existing) {
return existing
}
return prisma.borrowRecord.create({
data: seed
})
}
const ensureMaintenanceRecord = async (seed) => {
const existing = await prisma.maintenanceRecord.findFirst({
where: {
equipment_code: seed.equipment_code,
issue_description: seed.issue_description
}
})
if (existing) {
return prisma.maintenanceRecord.update({
where: { id: existing.id },
data: seed
})
}
return prisma.maintenanceRecord.create({ data: seed })
}
const ensureDisposalRecord = async (seed) => {
const existing = await prisma.disposalRecord.findFirst({
where: {
equipment_code: seed.equipment_code,
disposal_reason: seed.disposal_reason
}
})
if (existing) {
return prisma.disposalRecord.update({
where: { id: existing.id },
data: seed
})
}
return prisma.disposalRecord.create({ data: seed })
}
const ensureMountHistory = async (seed) => {
const existing = await prisma.mountHistory.findFirst({
where: {
child_id: seed.child_id,
parent_id: seed.parent_id,
operation_type: seed.operation_type,
notes: seed.notes
}
})
if (existing) {
return existing
}
return prisma.mountHistory.create({ data: seed })
}
const ensureOperationLog = async (seed) => {
const existing = await prisma.operationLog.findFirst({
where: {
operation_type: seed.operation_type,
equipment_code: seed.equipment_code || null,
operation_details: seed.operation_details
}
})
if (existing) {
return existing
}
return prisma.operationLog.create({ data: seed })
}
async function main() {
const admin = await prisma.user.findFirst({
where: { username: 'admin' },
select: { id: true, username: true, name: true }
})
if (!admin) {
throw new Error('未找到 admin 用户,无法写入测试数据')
}
const assetTypeNormalization = [
{ id: 1, name: '主机', short_name: 'PC' },
{ id: 2, name: 'cpu', short_name: 'CPU' },
{ id: 3, name: '其他外设', short_name: 'QT' },
{ id: 4, name: '内存条', short_name: 'NCT' },
{ id: 5, name: '显卡', short_name: 'XK' },
{ id: 6, name: '显示器', short_name: 'XSQ' },
{ id: 7, name: '硬盘', short_name: 'YP' },
{ id: 8, name: '笔记本', short_name: 'PC' }
]
for (const item of assetTypeNormalization) {
await prisma.assetType.update({
where: { id: item.id },
data: {
name: item.name,
short_name: item.short_name,
status: 'active'
}
})
}
const assetTypes = await prisma.assetType.findMany({
select: { id: true, name: true, short_name: true, can_be_parent: true, can_be_child: true }
})
const assetTypeMap = new Map(assetTypes.map((item) => [item.name, item]))
const requiredTypes = ['主机', 'cpu', '其他外设', '内存条', '显卡', '显示器', '硬盘', '笔记本']
for (const typeName of requiredTypes) {
if (!assetTypeMap.has(typeName)) {
throw new Error(`缺少资产类型:${typeName}`)
}
}
await prisma.assetType.updateMany({
where: { name: { in: ['主机', '笔记本'] } },
data: { can_be_parent: true }
})
await prisma.assetType.updateMany({
where: { name: { in: ['cpu', '其他外设', '内存条', '显卡', '硬盘'] } },
data: { can_be_child: true }
})
const locationMap = new Map()
for (const seed of locationSeeds) {
const location = await ensureLocation(seed)
locationMap.set(seed.key, location)
}
const departmentMap = new Map()
for (const [key, name] of Object.entries(departmentNameMap)) {
const department = await prisma.department.findFirst({
where: { status: 'active', name }
})
if (!department) {
throw new Error(`未找到启用部门:${name}`)
}
departmentMap.set(key, department)
}
const personMap = new Map()
for (const [key, keyword] of Object.entries(personDeptKeywordMap)) {
const person = await prisma.person.findFirst({
where: {
status: 'active',
department: { contains: keyword }
},
orderBy: { id: 'asc' }
})
if (!person) {
throw new Error(`未找到可用测试人员,部门关键字:${keyword}`)
}
personMap.set(key, person)
}
const siteConfigCount = await prisma.siteConfig.count()
if (siteConfigCount === 0) {
await prisma.siteConfig.create({
data: {
site_name: '简尚固定资产管理系统',
theme_color: '#2563eb',
updated_by: admin.id
}
})
}
const code = (typeName, locationKey, sequence) =>
buildEquipmentCode(
assetTypeMap.get(typeName).short_name,
locationMap.get(locationKey).id,
sequence
)
const equipmentSeeds = [
{
equipment_code: code('主机', 'hq', 1),
equipment_name: '研发部测试主机',
equipment_model: 'ThinkCentre M900',
equipment_type: '主机',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 5899,
description: `${TEST_TAG} 用于二维码打印、挂载测试的主机`,
purchase_date: new Date('2025-01-15'),
supplier: '联想授权渠道',
storage_location_id: locationMap.get('hq').id,
department_id: departmentMap.get('opsA').id,
asset_type_id: assetTypeMap.get('主机').id
},
{
equipment_code: code('主机', 'service', 1),
equipment_name: '客服前台主机',
equipment_model: 'Dell OptiPlex 7090',
equipment_type: '主机',
status: '已借出',
total_quantity: 1,
remaining_quantity: 0,
unit_price: 4680,
description: `${TEST_TAG} 当前借出状态演示`,
purchase_date: new Date('2025-02-20'),
supplier: '戴尔企业采购',
storage_location_id: locationMap.get('service').id,
department_id: departmentMap.get('service').id,
asset_type_id: assetTypeMap.get('主机').id
},
{
equipment_code: code('笔记本', 'office', 1),
equipment_name: '管理层备用笔记本',
equipment_model: 'ThinkPad T14',
equipment_type: '笔记本',
status: '已借出',
total_quantity: 1,
remaining_quantity: 0,
unit_price: 6999,
description: `${TEST_TAG} 长期借用演示`,
purchase_date: new Date('2025-03-18'),
supplier: '联想官方商城',
storage_location_id: locationMap.get('office').id,
department_id: departmentMap.get('management').id,
asset_type_id: assetTypeMap.get('笔记本').id
},
{
equipment_code: code('笔记本', 'office', 2),
equipment_name: '财务外勤笔记本',
equipment_model: 'HP ProBook 440',
equipment_type: '笔记本',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 5299,
description: `${TEST_TAG} 可借用笔记本`,
purchase_date: new Date('2024-12-01'),
supplier: '惠普商用渠道',
storage_location_id: locationMap.get('office').id,
department_id: departmentMap.get('finance').id,
asset_type_id: assetTypeMap.get('笔记本').id
},
{
equipment_code: code('显示器', 'service', 1),
equipment_name: '客服工位显示器',
equipment_model: 'AOC 24B2XH',
equipment_type: '显示器',
status: '维护中',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 899,
description: `${TEST_TAG} 维修流程演示`,
purchase_date: new Date('2024-11-06'),
supplier: 'AOC 官方旗舰店',
storage_location_id: locationMap.get('service').id,
department_id: departmentMap.get('service').id,
asset_type_id: assetTypeMap.get('显示器').id
},
{
equipment_code: code('显示器', 'office', 1),
equipment_name: '财务双屏显示器',
equipment_model: 'Dell P2422H',
equipment_type: '显示器',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 1199,
description: `${TEST_TAG} 正常在库显示器`,
purchase_date: new Date('2025-01-08'),
supplier: '戴尔企业采购',
storage_location_id: locationMap.get('office').id,
department_id: departmentMap.get('finance').id,
asset_type_id: assetTypeMap.get('显示器').id
},
{
equipment_code: code('其他外设', 'warehouse', 1),
equipment_name: '标签打印机',
equipment_model: 'Xprinter XP-H500E',
equipment_type: '其他外设',
status: '待报废',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 899,
description: `${TEST_TAG} 报废流程演示设备`,
purchase_date: new Date('2023-08-05'),
supplier: '芯烨官方店',
storage_location_id: locationMap.get('warehouse').id,
department_id: departmentMap.get('warehouse').id,
asset_type_id: assetTypeMap.get('其他外设').id
},
{
equipment_code: code('其他外设', 'office', 1),
equipment_name: '财务扫描枪',
equipment_model: 'Honeywell 1470g',
equipment_type: '其他外设',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 560,
description: `${TEST_TAG} 已完成维修历史演示`,
purchase_date: new Date('2024-06-11'),
supplier: '霍尼韦尔代理商',
storage_location_id: locationMap.get('office').id,
department_id: departmentMap.get('finance').id,
asset_type_id: assetTypeMap.get('其他外设').id
},
{
equipment_code: code('硬盘', 'warehouse', 1),
equipment_name: '淘汰机械硬盘',
equipment_model: 'Seagate 1TB HDD',
equipment_type: '硬盘',
status: '已报废',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 260,
description: `${TEST_TAG} 已完成报废演示设备`,
purchase_date: new Date('2022-02-10'),
supplier: '希捷经销商',
storage_location_id: locationMap.get('warehouse').id,
department_id: departmentMap.get('warehouse').id,
asset_type_id: assetTypeMap.get('硬盘').id
},
{
equipment_code: code('cpu', 'hq', 1),
equipment_name: '主机CPU',
equipment_model: 'Intel i5-12400',
equipment_type: 'cpu',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 1380,
description: `${TEST_TAG} 已挂载到研发部测试主机`,
purchase_date: new Date('2025-01-15'),
supplier: '英特尔代理',
storage_location_id: locationMap.get('hq').id,
department_id: departmentMap.get('opsA').id,
asset_type_id: assetTypeMap.get('cpu').id,
mount_status: 'mounted'
},
{
equipment_code: code('内存条', 'hq', 1),
equipment_name: '主机内存',
equipment_model: 'Kingston DDR4 16GB',
equipment_type: '内存条',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 320,
description: `${TEST_TAG} 已挂载到研发部测试主机`,
purchase_date: new Date('2025-01-15'),
supplier: '金士顿经销商',
storage_location_id: locationMap.get('hq').id,
department_id: departmentMap.get('opsA').id,
asset_type_id: assetTypeMap.get('内存条').id,
mount_status: 'mounted'
},
{
equipment_code: code('显卡', 'hq', 1),
equipment_name: '主机显卡',
equipment_model: 'NVIDIA T400',
equipment_type: '显卡',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 980,
description: `${TEST_TAG} 已挂载到研发部测试主机`,
purchase_date: new Date('2025-01-15'),
supplier: '英伟达代理',
storage_location_id: locationMap.get('hq').id,
department_id: departmentMap.get('opsA').id,
asset_type_id: assetTypeMap.get('显卡').id,
mount_status: 'mounted'
},
{
equipment_code: code('硬盘', 'hq', 1),
equipment_name: '主机固态硬盘',
equipment_model: 'Samsung PM9A1 1TB',
equipment_type: '硬盘',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 699,
description: `${TEST_TAG} 已挂载到研发部测试主机`,
purchase_date: new Date('2025-01-15'),
supplier: '三星渠道',
storage_location_id: locationMap.get('hq').id,
department_id: departmentMap.get('opsA').id,
asset_type_id: assetTypeMap.get('硬盘').id,
mount_status: 'mounted'
},
{
equipment_code: code('其他外设', 'service', 2),
equipment_name: '客服键鼠套装',
equipment_model: 'Logitech MK270',
equipment_type: '其他外设',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 149,
description: `${TEST_TAG} 已完成借还历史演示`,
purchase_date: new Date('2025-02-01'),
supplier: '罗技旗舰店',
storage_location_id: locationMap.get('service').id,
department_id: departmentMap.get('service').id,
asset_type_id: assetTypeMap.get('其他外设').id
},
{
equipment_code: code('其他外设', 'office', 2),
equipment_name: '会议摄像头',
equipment_model: 'Logitech C930e',
equipment_type: '其他外设',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 799,
description: `${TEST_TAG} 正常在库会议设备`,
purchase_date: new Date('2024-10-09'),
supplier: '罗技企业采购',
storage_location_id: locationMap.get('office').id,
department_id: departmentMap.get('management').id,
asset_type_id: assetTypeMap.get('其他外设').id
}
]
const equipmentMap = new Map()
for (const seed of equipmentSeeds) {
const equipment = await ensureEquipment(seed)
equipmentMap.set(seed.equipment_code, equipment)
}
const parentHost = equipmentMap.get(code('主机', 'hq', 1))
const mountedChildren = [
equipmentMap.get(code('cpu', 'hq', 1)),
equipmentMap.get(code('内存条', 'hq', 1)),
equipmentMap.get(code('显卡', 'hq', 1)),
equipmentMap.get(code('硬盘', 'hq', 1))
]
for (const child of mountedChildren) {
await prisma.equipment.update({
where: { id: child.id },
data: {
parent_id: parentHost.id,
mount_status: 'mounted'
}
})
await ensureMountHistory({
child_id: child.id,
parent_id: parentHost.id,
operation_type: 'mount',
operated_by: admin.id,
notes: `${TEST_TAG} 初始化挂载`
})
}
const childValue = mountedChildren.reduce((sum, item) => sum + Number(item.unit_price), 0)
await prisma.equipment.update({
where: { id: parentHost.id },
data: {
total_value_with_children: Number(parentHost.unit_price) + childValue
}
})
const borrowedHost = equipmentMap.get(code('主机', 'service', 1))
const borrowedLaptop = equipmentMap.get(code('笔记本', 'office', 1))
const historicalPeripheral = equipmentMap.get(code('其他外设', 'service', 2))
const maintenanceMonitor = equipmentMap.get(code('显示器', 'service', 1))
const disposedDisk = equipmentMap.get(code('硬盘', 'warehouse', 1))
const disposalPrinter = equipmentMap.get(code('其他外设', 'warehouse', 1))
const maintainedScanner = equipmentMap.get(code('其他外设', 'office', 1))
await ensureBorrowRecord({
equipment_id: borrowedHost.id,
equipment_code: borrowedHost.equipment_code,
person_id: personMap.get('service').id,
person_name: personMap.get('service').real_name,
person_nickname: personMap.get('service').nickname,
person_identifier: personMap.get('service').id_number,
borrow_time: dateOffset(-12),
expected_return_time: dateOffset(3),
is_long_term: false,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: `${TEST_TAG} 客服工位借用中`
})
await ensureBorrowRecord({
equipment_id: borrowedLaptop.id,
equipment_code: borrowedLaptop.equipment_code,
person_id: personMap.get('manager').id,
person_name: personMap.get('manager').real_name,
person_nickname: personMap.get('manager').nickname,
person_identifier: personMap.get('manager').id_number,
borrow_time: dateOffset(-25),
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: `${TEST_TAG} 管理层长期领用`
})
await ensureBorrowRecord({
equipment_id: historicalPeripheral.id,
equipment_code: historicalPeripheral.equipment_code,
person_id: personMap.get('ops').id,
person_name: personMap.get('ops').real_name,
person_nickname: personMap.get('ops').nickname,
person_identifier: personMap.get('ops').id_number,
borrow_time: dateOffset(-40),
expected_return_time: dateOffset(-33),
is_long_term: false,
return_time: dateOffset(-31),
status: '已归还',
return_condition: '正常',
notes: `${TEST_TAG} 已归还历史记录`
})
await ensureMaintenanceRecord({
equipment_id: maintenanceMonitor.id,
equipment_code: maintenanceMonitor.equipment_code,
issue_description: `${TEST_TAG} 屏幕闪烁,间歇性黑屏`,
repair_vendor: '常州本地维保点',
estimated_cost: 180,
actual_cost: null,
start_date: dateOffset(-2),
expected_end_date: dateOffset(5),
actual_end_date: null,
status: 'in_progress',
repair_result: null,
created_by: admin.id
})
await ensureMaintenanceRecord({
equipment_id: maintainedScanner.id,
equipment_code: maintainedScanner.equipment_code,
issue_description: `${TEST_TAG} 扫描头识别异常`,
repair_vendor: '苏州售后中心',
estimated_cost: 120,
actual_cost: 100,
start_date: dateOffset(-18),
expected_end_date: dateOffset(-12),
actual_end_date: dateOffset(-13),
status: 'completed',
repair_result: '更换扫描头后恢复正常',
created_by: admin.id
})
await ensureDisposalRecord({
equipment_id: disposalPrinter.id,
equipment_code: disposalPrinter.equipment_code,
disposal_reason: `${TEST_TAG} 主板老化,维修成本高于残值`,
disposal_method: '报废',
estimated_value: 80,
actual_value: null,
status: 'pending',
applied_by: admin.id,
applied_at: dateOffset(-1),
approved_by: null,
approved_at: null,
completed_at: null,
notes: `${TEST_TAG} 待审批报废单`
})
await ensureDisposalRecord({
equipment_id: disposedDisk.id,
equipment_code: disposedDisk.equipment_code,
disposal_reason: `${TEST_TAG} 盘体异响且坏道严重`,
disposal_method: '报废',
estimated_value: 20,
actual_value: 0,
status: 'completed',
applied_by: admin.id,
applied_at: dateOffset(-30),
approved_by: admin.id,
approved_at: dateOffset(-28),
completed_at: dateOffset(-25),
notes: `${TEST_TAG} 已完成报废处置`
})
await ensureOperationLog({
user_id: admin.id,
username: admin.username,
operation_type: '初始化测试数据',
equipment_code: null,
equipment_id: null,
operation_details: `${TEST_TAG} 写入演示资产、借用、维修、报废数据`,
ip_address: null
})
await ensureOperationLog({
user_id: admin.id,
username: admin.username,
operation_type: '初始化挂载关系',
equipment_code: parentHost.equipment_code,
equipment_id: parentHost.id,
operation_details: `${TEST_TAG} 为主机挂载 CPU/内存/显卡/硬盘`,
ip_address: null
})
const summary = {
storage_locations: await prisma.storageLocation.count(),
equipments: await prisma.equipment.count(),
borrow_records: await prisma.borrowRecord.count(),
maintenance_records: await prisma.maintenanceRecord.count(),
disposal_records: await prisma.disposalRecord.count(),
mounted_children: await prisma.equipment.count({ where: { parent_id: parentHost.id } })
}
console.log(JSON.stringify(summary, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -190,6 +190,14 @@ const getSourceDepartments = async (pool: any): Promise<SourceDepartment[]> => {
}))
}
const parseSourceDeptIds = (value: string | null) =>
(value || '')
.split('|')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => Number(item))
.filter((item) => !Number.isNaN(item))
const getSourcePersons = async (pool: any): Promise<SourcePerson[]> => {
const result = await pool
.request()
@ -251,6 +259,35 @@ const getSourcePersons = async (pool: any): Promise<SourcePerson[]> => {
.filter((item: SourcePerson) => isFormalEmployee(item))
}
const filterRelevantDepartments = (
allDepartments: SourceDepartment[],
sourcePersons: SourcePerson[]
): SourceDepartment[] => {
const relevantIds = new Set<number>()
const departmentMap = new Map(allDepartments.map((item) => [item.sourceGroupId, item]))
for (const person of sourcePersons) {
if (person.sourceMainDepartmentId) {
relevantIds.add(person.sourceMainDepartmentId)
}
for (const deptId of parseSourceDeptIds(person.sourceDeptIds)) {
relevantIds.add(deptId)
}
}
const scopedIds = new Set<number>(relevantIds)
for (const deptId of relevantIds) {
let current = departmentMap.get(deptId)
while (current?.sourceParentGroupId) {
scopedIds.add(current.sourceParentGroupId)
current = departmentMap.get(current.sourceParentGroupId)
}
}
return allDepartments.filter((item) => scopedIds.has(item.sourceGroupId))
}
const syncDepartments = async (sourceDepartments: SourceDepartment[]) => {
const existing = await prisma.department.findMany({
where: { source_system: SOURCE_SYSTEM },
@ -454,14 +491,16 @@ const writeSyncLog = async (result: HrSyncResult, operator = 'system-sync') => {
export const previewHrSync = async () => {
const pool = await sql.connect(getSqlServerConfig())
try {
const [departments, persons] = await Promise.all([
const [allDepartments, persons] = await Promise.all([
getSourceDepartments(pool),
getSourcePersons(pool)
])
const departments = filterRelevantDepartments(allDepartments, persons)
return {
departments: {
total: departments.length,
raw_total: allDepartments.length,
sample: departments.slice(0, 10)
},
persons: {
@ -477,10 +516,11 @@ export const previewHrSync = async () => {
export const runHrSync = async (operator = 'system-sync') => {
const pool = await sql.connect(getSqlServerConfig())
try {
const [departments, persons] = await Promise.all([
const [allDepartments, persons] = await Promise.all([
getSourceDepartments(pool),
getSourcePersons(pool)
])
const departments = filterRelevantDepartments(allDepartments, persons)
const departmentResult = await syncDepartments(departments)
const personResult = await syncPersons(persons)

View File

@ -1,6 +1,7 @@
server {
listen 80;
server_name localhost;
charset utf-8;
root /usr/share/nginx/html;
index index.html;

View File

@ -1,7 +1,10 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="grid gap-4 md:grid-cols-3 md:items-end">
<div class="rounded-apple bg-apple-blue/10 px-4 py-3 text-apple-footnote text-system-label-secondary">
固定资产系统的部门会按“正式员工所在部门 + 上级链路”同步,这里展示的是可用于资产归属的部门树,不再按源库全量组织直接照搬。
</div>
<div class="mt-4 grid gap-4 md:grid-cols-3 md:items-end">
<div class="md:col-span-2">
<label class="block text-apple-body text-system-label-primary mb-2">搜索使用部门</label>
<input
@ -39,12 +42,12 @@
<label class="block text-apple-body text-system-label-primary mb-2">上级部门</label>
<select v-model="createForm.parent_id" class="input-apple">
<option :value="null">无(作为一级部门)</option>
<option v-for="dept in rootDepartments" :key="dept.id" :value="dept.id">
{{ dept.name }}
<option v-for="dept in parentOptions" :key="dept.id" :value="dept.id">
{{ dept.optionLabel }}
</option>
</select>
<p class="text-apple-footnote text-system-label-tertiary mt-1">
选择上级部门后,该部门将作为二级部门
可挂到任意已启用部门下,支持多级结构
</p>
</div>
</div>
@ -59,7 +62,12 @@
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<h2 class="text-apple-title">使用部门列表</h2>
<div>
<h2 class="text-apple-title">使用部门列表</h2>
<p class="mt-1 text-apple-footnote text-system-label-tertiary">
当前共 {{ displayedDepartments.length }} 个部门节点
</p>
</div>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchDepartments">
刷新
</button>
@ -76,123 +84,71 @@
</tr>
</thead>
<tbody>
<template v-for="department in treeData" :key="department.id">
<!-- 父部门行 -->
<tr class="border-b border-system-gray-6 text-system-label-primary text-apple-body bg-system-gray-6/30">
<td class="py-3 px-4">
<div class="flex items-center gap-2">
<span v-if="department.children && department.children.length > 0" class="text-apple-blue">📁</span>
<span v-else class="text-system-label-tertiary">📄</span>
<div>
<div class="font-medium">{{ department.name }}</div>
<div class="text-apple-footnote text-system-label-tertiary">
更新于 {{ formatDateTime(department.updated_at) }}
</div>
<tr
v-for="department in displayedDepartments"
:key="department.id"
class="border-b border-system-gray-6 text-system-label-primary text-apple-body"
>
<td class="py-3 px-4">
<div class="flex items-center gap-2" :style="{ paddingLeft: `${department.level * 20}px` }">
<span v-if="department.children_count > 0" class="text-apple-blue">📁</span>
<span v-else class="text-system-label-tertiary">📄</span>
<div>
<div class="font-medium">{{ department.name }}</div>
<div class="text-apple-footnote text-system-label-tertiary">
<span v-if="department.parent_name">上级:{{ department.parent_name }} · </span>
更新于 {{ formatDateTime(department.updated_at) }}
</div>
</div>
</td>
<td class="py-3 px-4">
<span class="rounded-apple px-2 py-1 text-apple-footnote bg-apple-blue/15 text-apple-blue">
一级部门
</span>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="department.status === 'active' ? 'bg-apple-green/15 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ department.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td class="py-3 px-4 text-apple-body">
{{ department.equipment_count ?? 0 }}
<span v-if="department.children && department.children.length > 0" class="text-system-label-tertiary">
(含子部门: {{ getTotalEquipmentCount(department) }})
</span>
</td>
<td class="py-3 px-4 text-right space-x-3">
<button class="text-apple-blue hover:underline" @click="openEdit(department)">
编辑
</button>
<button
class="text-apple-orange hover:underline"
@click="toggleStatus(department)"
>
{{ department.status === 'active' ? '停用' : '启用' }}
</button>
<button
class="text-apple-red hover:underline"
:disabled="(department.children && department.children.length > 0) || department.equipment_count > 0"
:class="{ 'opacity-50 cursor-not-allowed': (department.children && department.children.length > 0) || department.equipment_count > 0 }"
@click="handleDelete(department)"
>
删除
</button>
</td>
</tr>
<!-- 子部门行 -->
<tr
v-for="child in department.children"
:key="child.id"
class="border-b border-system-gray-6 text-system-label-primary text-apple-body"
>
<td class="py-3 px-4 pl-10">
<div class="flex items-center gap-2">
<span class="text-system-label-tertiary">└</span>
<div>
<div class="font-medium">{{ child.name }}</div>
<div class="text-apple-footnote text-system-label-tertiary">
更新于 {{ formatDateTime(child.updated_at) }}
</div>
</div>
</div>
</td>
<td class="py-3 px-4">
<span class="rounded-apple px-2 py-1 text-apple-footnote bg-apple-orange/15 text-apple-orange">
二级部门
</span>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="child.status === 'active' ? 'bg-apple-green/15 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ child.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td class="py-3 px-4 text-apple-body">
{{ child.equipment_count ?? 0 }}
</td>
<td class="py-3 px-4 text-right space-x-3">
<button class="text-apple-blue hover:underline" @click="openEdit(child, department)">
编辑
</button>
<button
class="text-apple-orange hover:underline"
@click="toggleStatus(child)"
>
{{ child.status === 'active' ? '停用' : '启用' }}
</button>
<button
class="text-apple-red hover:underline"
:disabled="child.equipment_count > 0"
:class="{ 'opacity-50 cursor-not-allowed': child.equipment_count > 0 }"
@click="handleDelete(child)"
>
删除
</button>
</td>
</tr>
</template>
</div>
</td>
<td class="py-3 px-4">
<span class="rounded-apple px-2 py-1 text-apple-footnote bg-apple-blue/15 text-apple-blue">
{{ getLevelLabel(department.level) }}
</span>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="department.status === 'active' ? 'bg-apple-green/15 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ department.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td class="py-3 px-4 text-apple-body">
{{ department.equipment_count ?? 0 }}
<span v-if="department.children_count > 0" class="text-system-label-tertiary">
(含下级: {{ getTotalEquipmentCount(department) }})
</span>
</td>
<td class="py-3 px-4 text-right space-x-3">
<button class="text-apple-blue hover:underline" @click="openEdit(department)">
编辑
</button>
<button
class="text-apple-orange hover:underline"
@click="toggleStatus(department)"
>
{{ department.status === 'active' ? '停用' : '启用' }}
</button>
<button
class="text-apple-red hover:underline"
:disabled="department.children_count > 0 || department.equipment_count > 0"
:class="{ 'opacity-50 cursor-not-allowed': department.children_count > 0 || department.equipment_count > 0 }"
@click="handleDelete(department)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!loading && treeData.length === 0" class="mt-6 text-center text-system-label-tertiary">
<p v-if="!loading && displayedDepartments.length === 0" class="mt-6 text-center text-system-label-tertiary">
暂无使用部门数据。
</p>
</section>
<!-- 编辑弹窗 -->
<div
v-if="editingDepartment"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4"
@ -206,22 +162,22 @@
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">上级部门</label>
<select
v-model="editDraft.parent_id"
<select
v-model="editDraft.parent_id"
class="input-apple"
:disabled="editDraft.hasChildren"
>
<option :value="null">无(作为一级部门)</option>
<option
v-for="dept in rootDepartments.filter(d => d.id !== editDraft.id)"
:key="dept.id"
<option
v-for="dept in parentOptions.filter((item) => item.id !== editDraft.id)"
:key="dept.id"
:value="dept.id"
>
{{ dept.name }}
{{ dept.optionLabel }}
</option>
</select>
<p v-if="editDraft.hasChildren" class="text-apple-footnote text-apple-orange mt-1">
该部门下有子部门,不能修改为二级部门
该部门下有子部门,暂不允许直接改挂到其他父级
</p>
</div>
<div>
@ -244,7 +200,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, computed } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { formatDateTime } from '@/utils/format'
import {
getDepartmentTree,
@ -254,28 +210,29 @@ import {
} from '@/api/department'
import type { DepartmentTreeNode } from '@/types'
type FlatDepartmentNode = DepartmentTreeNode & {
level: number
parent_name: string | null
optionLabel: string
}
const loading = ref(false)
const createLoading = ref(false)
const updateLoading = ref(false)
const filters = reactive({
keyword: '',
status: ''
status: 'active'
})
const treeData = ref<DepartmentTreeNode[]>([])
// 获取所有根部门(用于父部门选择)
const rootDepartments = computed(() => {
return treeData.value.filter(d => d.status === 'active')
})
const createForm = reactive({
name: '',
parent_id: null as number | null
})
const editingDepartment = ref<DepartmentTreeNode | null>(null)
const editingDepartment = ref<FlatDepartmentNode | null>(null)
const editDraft = reactive({
id: 0,
name: '',
@ -284,48 +241,78 @@ const editDraft = reactive({
hasChildren: false
})
const flattenDepartments = (
nodes: DepartmentTreeNode[],
level = 0,
parentName: string | null = null
): FlatDepartmentNode[] => {
const list: FlatDepartmentNode[] = []
for (const node of nodes) {
list.push({
...node,
level,
parent_name: parentName,
optionLabel: `${level > 0 ? `${'— '.repeat(level)}` : ''}${node.name}`
})
if (node.children?.length) {
list.push(...flattenDepartments(node.children, level + 1, node.name))
}
}
return list
}
const flatDepartments = computed(() => flattenDepartments(treeData.value))
const parentOptions = computed(() =>
flatDepartments.value.filter((item) => item.status === 'active')
)
const displayedDepartments = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return flatDepartments.value.filter((item) => {
const matchKeyword =
!keyword ||
item.name.toLowerCase().includes(keyword) ||
(item.parent_name || '').toLowerCase().includes(keyword)
const matchStatus = !filters.status || item.status === filters.status
return matchKeyword && matchStatus
})
})
const canCreate = computed(() => createForm.name.trim().length > 0)
const getTotalEquipmentCount = (dept: FlatDepartmentNode): number => {
const findNode = (nodes: DepartmentTreeNode[]): DepartmentTreeNode | null => {
for (const node of nodes) {
if (node.id === dept.id) return node
if (node.children?.length) {
const matched = findNode(node.children)
if (matched) return matched
}
}
return null
}
const calc = (node: DepartmentTreeNode): number =>
(node.equipment_count || 0) + (node.children || []).reduce((sum, child) => sum + calc(child), 0)
const target = findNode(treeData.value)
return target ? calc(target) : dept.equipment_count || 0
}
const getLevelLabel = (level: number) => `第 ${level + 1} 级`
const resetCreateForm = () => {
createForm.name = ''
createForm.parent_id = null
}
// 计算父部门及其子部门的总资产数
const getTotalEquipmentCount = (dept: DepartmentTreeNode): number => {
let total = dept.equipment_count || 0
if (dept.children) {
for (const child of dept.children) {
total += child.equipment_count || 0
}
}
return total
}
const fetchDepartments = async () => {
loading.value = true
try {
const response = await getDepartmentTree()
if (response.data) {
let data = response.data
// 应用过滤
if (filters.keyword) {
const keyword = filters.keyword.toLowerCase()
data = data.filter(d =>
d.name.toLowerCase().includes(keyword) ||
d.children?.some(c => c.name.toLowerCase().includes(keyword))
).map(d => ({
...d,
children: d.children?.filter(c =>
c.name.toLowerCase().includes(keyword) ||
d.name.toLowerCase().includes(keyword)
) || []
}))
}
if (filters.status) {
data = data.filter(d => d.status === filters.status)
}
treeData.value = data
treeData.value = response.data
}
} catch (error) {
console.error('获取使用部门数据失败:', error)
@ -351,13 +338,13 @@ const handleCreate = async () => {
}
}
const openEdit = (department: DepartmentTreeNode, parent?: DepartmentTreeNode) => {
const openEdit = (department: FlatDepartmentNode) => {
editingDepartment.value = department
editDraft.id = department.id
editDraft.name = department.name
editDraft.status = department.status
editDraft.parent_id = parent ? parent.id : department.parent_id
editDraft.hasChildren = (department.children?.length || 0) > 0
editDraft.parent_id = department.parent_id
editDraft.hasChildren = department.children_count > 0
}
const closeEdit = () => {
@ -382,7 +369,7 @@ const submitEdit = async () => {
}
}
const toggleStatus = async (department: DepartmentTreeNode) => {
const toggleStatus = async (department: FlatDepartmentNode) => {
try {
const targetStatus = department.status === 'active' ? 'inactive' : 'active'
await updateDepartment(department.id, { status: targetStatus })
@ -392,8 +379,8 @@ const toggleStatus = async (department: DepartmentTreeNode) => {
}
}
const handleDelete = async (department: DepartmentTreeNode) => {
if (department.children && department.children.length > 0) {
const handleDelete = async (department: FlatDepartmentNode) => {
if (department.children_count > 0) {
alert('该部门下存在子部门,请先删除子部门')
return
}

View File

@ -1,7 +1,10 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="rounded-apple bg-apple-blue/10 px-4 py-3 text-apple-footnote text-system-label-secondary">
这里是资产“使用者”信息,可从钉钉人员资料同步;它不等于“用户管理”里的登录账号。
</div>
<div class="mt-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="flex-1">
<label class="block text-apple-body text-system-label-primary mb-2">搜索使用者</label>
<input
@ -12,18 +15,17 @@
@keyup.enter="fetchPersons"
/>
</div>
<div class="flex space-x-3">
<div class="flex flex-wrap gap-3">
<select v-model="statusFilter" class="input-apple">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
<button class="btn-apple-secondary" :disabled="listLoading" @click="fetchPersons">
查询
</button>
<button class="btn-apple-secondary" :disabled="listLoading" @click="fetchPersons">查询</button>
<button class="btn-apple-primary" @click="showCreateModal = true">新增使用者</button>
</div>
</div>
<div class="flex space-x-3 mt-4 pt-4 border-t border-system-gray-5">
<div class="flex flex-wrap gap-3 mt-4 pt-4 border-t border-system-gray-5">
<button class="btn-apple-secondary" @click="downloadTemplate">下载导入模板</button>
<label class="btn-apple-primary cursor-pointer">
批量导入
@ -33,56 +35,13 @@
</section>
<section class="card-apple">
<h2 class="text-apple-title mb-4">新增使用者</h2>
<div class="grid gap-4 md:grid-cols-2">
<div class="flex items-center justify-between mb-4 gap-4">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">花名 *</label>
<input v-model.trim="newPerson.nickname" class="input-apple" type="text" placeholder="花名 / 昵称" />
<h2 class="text-apple-title">使用者列表</h2>
<p class="mt-1 text-apple-footnote text-system-label-tertiary">
当前共 {{ total }} 人,本页已加载 {{ persons.length }} 人
</p>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">真实姓名 *</label>
<input v-model.trim="newPerson.real_name" class="input-apple" type="text" placeholder="真实姓名" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">证件编号</label>
<input v-model.trim="newPerson.id_number" class="input-apple" type="text" placeholder="身份证 / 工号(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">部门</label>
<input v-model.trim="newPerson.department" class="input-apple" type="text" placeholder="部门(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">职位</label>
<input v-model.trim="newPerson.position" class="input-apple" type="text" placeholder="职位(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">手机号</label>
<input v-model.trim="newPerson.phone" class="input-apple" type="tel" placeholder="手机号(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">邮箱</label>
<input v-model.trim="newPerson.email" class="input-apple" type="email" placeholder="邮箱(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="newPerson.status" class="input-apple">
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
</div>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<textarea v-model.trim="newPerson.remarks" class="input-apple" rows="3" placeholder="备注信息(可选)"></textarea>
</div>
<button class="btn-apple-primary mt-4" :disabled="createLoading || !canCreate" @click="handleCreatePerson">
{{ createLoading ? '提交中...' : '新增使用者' }}
</button>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<h2 class="text-apple-title">使用者列表</h2>
<button class="btn-apple-secondary" :disabled="listLoading" @click="fetchPersons">刷新</button>
</div>
<div class="overflow-x-auto">
@ -99,7 +58,11 @@
</tr>
</thead>
<tbody>
<tr v-for="person in persons" :key="person.id" class="border-b border-system-gray-6 text-apple-body text-system-label-primary">
<tr
v-for="person in persons"
:key="person.id"
class="border-b border-system-gray-6 text-apple-body text-system-label-primary"
>
<td class="py-3 px-4">{{ person.nickname }}</td>
<td class="py-3 px-4">{{ person.real_name }}</td>
<td class="py-3 px-4 font-mono text-apple-footnote text-system-label-secondary">{{ person.id_number || '-' }}</td>
@ -112,13 +75,18 @@
<p v-if="person.email">📧 {{ person.email }}</p>
</td>
<td class="py-3 px-4">
<span class="rounded-apple px-3 py-1 text-apple-footnote" :class="person.status === 'active' ? 'bg-apple-green/10 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="person.status === 'active' ? 'bg-apple-green/10 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ person.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="py-3 px-4 text-right space-x-2">
<button class="text-apple-blue hover:underline" @click="openEdit(person)">编辑</button>
<button class="text-apple-red hover:underline" @click="toggleStatus(person)">{{ person.status === 'active' ? '禁用' : '启用' }}</button>
<button class="text-apple-red hover:underline" @click="toggleStatus(person)">
{{ person.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="text-apple-red hover:underline" @click="handleDeletePerson(person)">删除</button>
</td>
</tr>
@ -128,6 +96,59 @@
<p v-if="!listLoading && persons.length === 0" class="mt-6 text-center text-system-label-tertiary">暂无使用者数据。</p>
</section>
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">新增使用者</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">花名 *</label>
<input v-model.trim="newPerson.nickname" class="input-apple" type="text" placeholder="花名 / 昵称" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">真实姓名 *</label>
<input v-model.trim="newPerson.real_name" class="input-apple" type="text" placeholder="真实姓名" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">证件编号</label>
<input v-model.trim="newPerson.id_number" class="input-apple" type="text" placeholder="身份证 / 工号(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">部门</label>
<input v-model.trim="newPerson.department" class="input-apple" type="text" placeholder="部门(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">职位</label>
<input v-model.trim="newPerson.position" class="input-apple" type="text" placeholder="职位(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">手机号</label>
<input v-model.trim="newPerson.phone" class="input-apple" type="tel" placeholder="手机号(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">邮箱</label>
<input v-model.trim="newPerson.email" class="input-apple" type="email" placeholder="邮箱(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="newPerson.status" class="input-apple">
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
</div>
</div>
<div class="mt-4">
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<textarea v-model.trim="newPerson.remarks" class="input-apple" rows="3" placeholder="备注信息(可选)"></textarea>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button class="btn-apple-secondary" @click="closeCreateModal">取消</button>
<button class="btn-apple-primary" :disabled="createLoading || !canCreate" @click="handleCreatePerson">
{{ createLoading ? '提交中...' : '新增使用者' }}
</button>
</div>
</div>
</div>
<div v-if="editingPerson" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">编辑使用者</h3>
@ -168,8 +189,8 @@
</select>
</div>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2 mt-4">备注</label>
<div class="mt-4">
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<textarea v-model.trim="editDraft.remarks" class="input-apple" rows="3"></textarea>
</div>
<div class="mt-6 flex justify-end space-x-3">
@ -189,9 +210,11 @@ import { createPerson, getPersonList, updatePerson, updatePersonStatus, deletePe
import { batchImportPersons } from '@/api/batchImport'
const persons = ref<Person[]>([])
const total = ref(0)
const listLoading = ref(false)
const createLoading = ref(false)
const updateLoading = ref(false)
const showCreateModal = ref(false)
const keyword = ref('')
const statusFilter = ref('')
@ -209,15 +232,38 @@ const canCreate = computed(() => newPerson.nickname.trim() && newPerson.real_nam
const fetchPersons = async () => {
listLoading.value = true
try {
const response = await getPersonList({ page: 1, pageSize: 100, keyword: keyword.value || undefined, status: statusFilter.value || undefined })
if (response.data) persons.value = response.data.list
} catch (error) { console.error('获取使用者列表失败', error) }
finally { listLoading.value = false }
const response = await getPersonList({
page: 1,
pageSize: 500,
keyword: keyword.value || undefined,
status: statusFilter.value || undefined
})
if (response.data) {
persons.value = response.data.list
total.value = response.data.total
}
} catch (error) {
console.error('获取使用者列表失败', error)
} finally {
listLoading.value = false
}
}
const resetNewForm = () => {
newPerson.nickname = ''; newPerson.real_name = ''; newPerson.id_number = ''; newPerson.department = ''
newPerson.position = ''; newPerson.phone = ''; newPerson.email = ''; newPerson.status = 'active'; newPerson.remarks = ''
newPerson.nickname = ''
newPerson.real_name = ''
newPerson.id_number = ''
newPerson.department = ''
newPerson.position = ''
newPerson.phone = ''
newPerson.email = ''
newPerson.status = 'active'
newPerson.remarks = ''
}
const closeCreateModal = () => {
showCreateModal.value = false
resetNewForm()
}
const handleCreatePerson = async () => {
@ -225,116 +271,167 @@ const handleCreatePerson = async () => {
try {
createLoading.value = true
await createPerson({
nickname: newPerson.nickname, real_name: newPerson.real_name,
id_number: newPerson.id_number || undefined, department: newPerson.department || undefined,
position: newPerson.position || undefined, phone: newPerson.phone || undefined,
email: newPerson.email || undefined, status: newPerson.status, remarks: newPerson.remarks || undefined
nickname: newPerson.nickname,
real_name: newPerson.real_name,
id_number: newPerson.id_number || undefined,
department: newPerson.department || undefined,
position: newPerson.position || undefined,
phone: newPerson.phone || undefined,
email: newPerson.email || undefined,
status: newPerson.status,
remarks: newPerson.remarks || undefined
})
alert('使用者创建成功'); resetNewForm(); fetchPersons()
} catch (error: any) { alert(error?.message || '创建使用者失败') }
finally { createLoading.value = false }
alert('使用者创建成功')
closeCreateModal()
fetchPersons()
} catch (error: any) {
alert(error?.message || '创建使用者失败')
} finally {
createLoading.value = false
}
}
const openEdit = (person: Person) => {
editingPerson.value = person; editDraft.id = person.id; editDraft.nickname = person.nickname
editDraft.real_name = person.real_name; editDraft.id_number = person.id_number || ''
editDraft.department = person.department || ''; editDraft.position = person.position || ''
editDraft.phone = person.phone || ''; editDraft.email = person.email || ''
editDraft.status = person.status; editDraft.remarks = person.remarks || ''
editingPerson.value = person
editDraft.id = person.id
editDraft.nickname = person.nickname
editDraft.real_name = person.real_name
editDraft.id_number = person.id_number || ''
editDraft.department = person.department || ''
editDraft.position = person.position || ''
editDraft.phone = person.phone || ''
editDraft.email = person.email || ''
editDraft.status = person.status
editDraft.remarks = person.remarks || ''
}
const closeEdit = () => { editingPerson.value = null }
const closeEdit = () => {
editingPerson.value = null
}
const submitEdit = async () => {
if (!editingPerson.value) return
try {
updateLoading.value = true
await updatePerson(editingPerson.value.id, {
nickname: editDraft.nickname, real_name: editDraft.real_name,
id_number: editDraft.id_number || undefined, department: editDraft.department || undefined,
position: editDraft.position || undefined, phone: editDraft.phone || undefined,
email: editDraft.email || undefined, status: editDraft.status, remarks: editDraft.remarks || undefined
nickname: editDraft.nickname,
real_name: editDraft.real_name,
id_number: editDraft.id_number || undefined,
department: editDraft.department || undefined,
position: editDraft.position || undefined,
phone: editDraft.phone || undefined,
email: editDraft.email || undefined,
status: editDraft.status,
remarks: editDraft.remarks || undefined
})
alert('使用者信息已更新'); closeEdit(); fetchPersons()
} catch (error: any) { alert(error?.message || '更新失败') }
finally { updateLoading.value = false }
alert('使用者信息已更新')
closeEdit()
fetchPersons()
} catch (error: any) {
alert(error?.message || '更新失败')
} finally {
updateLoading.value = false
}
}
const toggleStatus = async (person: Person) => {
const targetStatus = person.status === 'active' ? 'inactive' : 'active'
if (!confirm(`确定要${targetStatus === 'active' ? '启用' : '禁用'}该使用者吗?`)) return
try { await updatePersonStatus(person.id, targetStatus); fetchPersons() }
catch (error: any) { alert(error?.message || '更新状态失败') }
try {
await updatePersonStatus(person.id, targetStatus)
fetchPersons()
} catch (error: any) {
alert(error?.message || '更新状态失败')
}
}
const handleDeletePerson = async (person: Person) => {
if (!confirm(`确定要删除使用者 "${person.nickname}" 吗?此操作不可恢复。`)) return
try { await deletePerson(person.id); alert('删除成功'); fetchPersons() }
catch (error: any) { alert(error?.message || '删除失败') }
try {
await deletePerson(person.id)
alert('删除成功')
fetchPersons()
} catch (error: any) {
alert(error?.message || '删除失败')
}
}
onMounted(() => { fetchPersons() })
onMounted(() => {
fetchPersons()
})
const downloadTemplate = () => {
// 调用后端接口下载带下拉选项的模板
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
window.open(`${baseUrl}/export/person-template`, '_blank')
}
// 解析文件内容(支持 CSV 和 Excel)
const parseFileContent = async (file: File): Promise<string[][]> => {
const buffer = await file.arrayBuffer()
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext === 'xlsx' || ext === 'xls') {
// Excel 文件
const workbook = XLSX.read(buffer, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
const data = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
return data
return XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
}
const bytes = new Uint8Array(buffer)
const hasUtf8Bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
let text: string
if (hasUtf8Bom) {
text = new TextDecoder('utf-8').decode(bytes)
} else {
// CSV 文件
const bytes = new Uint8Array(buffer)
const hasUtf8Bom = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF
let text: string
if (hasUtf8Bom) {
text = new TextDecoder('utf-8').decode(bytes)
const utf8Text = new TextDecoder('utf-8').decode(bytes)
if (!utf8Text.includes('\uFFFD') && (/[\u4e00-\u9fa5]/.test(utf8Text) || !/[^\x00-\x7F]/.test(utf8Text))) {
text = utf8Text
} else {
const utf8Text = new TextDecoder('utf-8').decode(bytes)
if (!utf8Text.includes('\uFFFD') && (/[\u4e00-\u9fa5]/.test(utf8Text) || !/[^\x00-\x7F]/.test(utf8Text))) {
try {
text = new TextDecoder('gbk').decode(bytes)
} catch {
text = utf8Text
} else {
try { text = new TextDecoder('gbk').decode(bytes) } catch { text = utf8Text }
}
}
return text.split('\n').filter(line => line.trim()).map(line =>
line.split(',').map(c => c.trim().replace(/^"|"$/g, '').replace(/\r/g, ''))
)
}
return text
.split('\n')
.filter((line) => line.trim())
.map((line) => line.split(',').map((c) => c.trim().replace(/^"|"$/g, '').replace(/\r/g, '')))
}
const handleFileImport = async (event: Event) => {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
const file = input.files[0]
try {
const rows = await parseFileContent(file)
if (rows.length < 2) { alert('文件内容为空或格式不正确'); input.value = ''; return }
const personList = rows.slice(1).map(cols => ({
nickname: String(cols[0] || '').trim(),
real_name: String(cols[1] || '').trim(),
id_number: cols[2] ? String(cols[2]).trim() : undefined,
department: cols[3] ? String(cols[3]).trim() : undefined,
position: cols[4] ? String(cols[4]).trim() : undefined,
phone: cols[5] ? String(cols[5]).trim() : undefined,
email: cols[6] ? String(cols[6]).trim() : undefined
})).filter(p => p.nickname && p.real_name)
if (personList.length === 0) { alert('没有有效的导入数据'); input.value = ''; return }
if (rows.length < 2) {
alert('文件内容为空或格式不正确')
input.value = ''
return
}
const personList = rows
.slice(1)
.map((cols) => ({
nickname: String(cols[0] || '').trim(),
real_name: String(cols[1] || '').trim(),
id_number: cols[2] ? String(cols[2]).trim() : undefined,
department: cols[3] ? String(cols[3]).trim() : undefined,
position: cols[4] ? String(cols[4]).trim() : undefined,
phone: cols[5] ? String(cols[5]).trim() : undefined,
email: cols[6] ? String(cols[6]).trim() : undefined
}))
.filter((p) => p.nickname && p.real_name)
if (personList.length === 0) {
alert('没有有效的导入数据')
input.value = ''
return
}
const response = await batchImportPersons(personList)
const apiData = response.data
if (apiData) {
@ -343,9 +440,13 @@ const handleFileImport = async (event: Event) => {
msg += '\n\n错误详情:\n' + apiData.errors.slice(0, 5).map((e: { row: number; message: string }) => `第${e.row}行: ${e.message}`).join('\n')
if (apiData.errors.length > 5) msg += `\n...还有 ${apiData.errors.length - 5} 条错误`
}
alert(msg); fetchPersons()
alert(msg)
fetchPersons()
}
} catch (error: any) { alert(error?.message || '导入失败') }
} catch (error: any) {
alert(error?.message || '导入失败')
}
input.value = ''
}
</script>

View File

@ -1,7 +1,10 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<h2 class="text-apple-title mb-4">新增用户</h2>
<div class="rounded-apple bg-system-gray-6 px-4 py-3 text-apple-footnote text-system-label-secondary">
这里管理的是系统登录账号,不会跟随钉钉人员同步自动增减。
</div>
<h2 class="text-apple-title mt-4 mb-4">新增用户</h2>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">用户名 *</label>
@ -323,4 +326,3 @@ onMounted(() => {
fetchUsers()
})
</script>