完善人员同步与设置页并修复中文乱码
This commit is contained in:
parent
232c350621
commit
65042bf9c9
@ -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"
|
||||
|
||||
70
backend/scripts/repairMojibake.js
Normal file
70
backend/scripts/repairMojibake.js
Normal 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()
|
||||
})
|
||||
755
backend/scripts/seedDemoData.js
Normal file
755
backend/scripts/seedDemoData.js
Normal 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()
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user