完善组织同步盘点与资产迁移链路

This commit is contained in:
WuYongJie 2026-03-23 11:54:41 +08:00
parent c3f8eb6f6f
commit ceacc2f98d
62 changed files with 13283 additions and 1868 deletions

15
.gitignore vendored
View File

@ -11,3 +11,18 @@ frontend/vite.config.js
frontend/vite.config.d.ts
frontend/public/branding/custom-logo.*
frontend/public/branding/custom-favicon.*
backend/*.log
frontend/*.log
error.log
temp_migration_output/
8.xlsx
~$*.xlsx
行政手动补录清单*.xlsx
地址标准化候选.csv
01-login-page.png
02-dashboard-overview.png
03-equipment-list.png
04-borrow-management.png
05-settings-page.png
nul
{

View File

@ -136,8 +136,9 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS=
- `HR_SYNC_ENABLED=true` 时,后端会启动每日定时同步
- `HR_SYNC_SCHEDULE` 建议使用 `03:00`
- `HR_SYNC_ALLOWED_STATUS_VALUES` 用来限定“哪些源状态算正式员工”,当前建议 `3`
- `HR_SYNC_ALLOWED_STATUS_VALUES` 用来标记“哪些源状态算正式员工”,当前建议 `3`
- `HR_SYNC_INACTIVE_STATUS_VALUES` 用来标记源系统里的非在职状态码
- 当前组织同步默认以 `hr_employee_register` 为主口径,`hr_employee` 只用于补充姓名、手机、邮箱、岗位等字段
- `HR_SYNC_EXCLUDED_DEPARTMENTS` 用来排除供应商、快递公司等外部对象
- `HR_SYNC_EXCLUDED_NAME_KEYWORDS` 可选,用来额外排除名字特征
- 如果源库是较老版本 SQL Server,建议 `SQLSERVER_ENCRYPT=false`

View File

@ -0,0 +1,184 @@
ALTER TABLE `person`
ADD COLUMN `source_department_name` VARCHAR(100) NULL AFTER `source_dept_ids`,
ADD COLUMN `source_main_department_name` VARCHAR(100) NULL AFTER `source_department_name`,
ADD COLUMN `department_sync_bucket` VARCHAR(20) NULL AFTER `source_main_department_name`;
CREATE INDEX `person_department_sync_bucket_idx`
ON `person`(`department_sync_bucket`);
CREATE TABLE `inventory_task` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`description` TEXT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`public_token` VARCHAR(120) NOT NULL,
`scope_department_id` INT NULL,
`scope_department_name` VARCHAR(100) NULL,
`scope_asset_type_id` INT NULL,
`scope_asset_type_name` VARCHAR(100) NULL,
`scope_snapshot_json` TEXT NULL,
`participants_count` INT NOT NULL DEFAULT 0,
`items_count` INT NOT NULL DEFAULT 0,
`completed_count` INT NOT NULL DEFAULT 0,
`exception_count` INT NOT NULL DEFAULT 0,
`unassigned_count` INT NOT NULL DEFAULT 0,
`created_by` INT NOT NULL,
`closed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `inventory_task_public_token_key`(`public_token`),
INDEX `inventory_task_status_created_at_idx`(`status`, `created_at`),
INDEX `inventory_task_scope_department_id_status_idx`(`scope_department_id`, `status`),
INDEX `inventory_task_scope_asset_type_id_status_idx`(`scope_asset_type_id`, `status`),
INDEX `inventory_task_created_by_idx`(`created_by`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_scope_department_id_fkey`
FOREIGN KEY (`scope_department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_scope_asset_type_id_fkey`
FOREIGN KEY (`scope_asset_type_id`) REFERENCES `asset_type`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_created_by_fkey`
FOREIGN KEY (`created_by`) REFERENCES `user`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `inventory_task_participant` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`person_id` INT NULL,
`person_name` VARCHAR(50) NOT NULL,
`person_nickname` VARCHAR(50) NOT NULL,
`person_source_userid` VARCHAR(100) NULL,
`department_id` INT NULL,
`department_name` VARCHAR(100) NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`confirmed_at` DATETIME(3) NULL,
`last_action_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `inventory_task_participant_task_id_person_id_key`(`task_id`, `person_id`),
INDEX `inventory_task_participant_task_id_status_idx`(`task_id`, `status`),
INDEX `inventory_task_participant_task_id_person_id_idx`(`task_id`, `person_id`),
INDEX `inventory_task_participant_task_id_person_nickname_idx`(`task_id`, `person_nickname`),
INDEX `inventory_task_participant_department_id_idx`(`department_id`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_participant_task_id_fkey`
FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_participant_person_id_fkey`
FOREIGN KEY (`person_id`) REFERENCES `person`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_participant_department_id_fkey`
FOREIGN KEY (`department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `inventory_task_item` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`participant_id` INT NULL,
`equipment_id` INT NOT NULL,
`equipment_code` VARCHAR(50) NOT NULL,
`equipment_name` VARCHAR(100) NOT NULL,
`equipment_model` VARCHAR(100) NULL,
`equipment_type` VARCHAR(50) NOT NULL,
`asset_type_id` INT NULL,
`asset_type_name` VARCHAR(100) NULL,
`snapshot_department_id` INT NULL,
`snapshot_department_name` VARCHAR(100) NULL,
`snapshot_person_id` INT NULL,
`snapshot_person_name` VARCHAR(50) NULL,
`snapshot_person_nickname` VARCHAR(50) NULL,
`snapshot_status` VARCHAR(20) NULL,
`source_borrow_record_id` INT NULL,
`is_unassigned` BOOLEAN NOT NULL DEFAULT false,
`check_status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`checked_by_participant_id` INT NULL,
`checked_at` DATETIME(3) NULL,
`exception_type` VARCHAR(30) NULL,
`exception_note` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `inventory_task_item_task_id_equipment_id_key`(`task_id`, `equipment_id`),
INDEX `inventory_task_item_task_id_participant_id_check_status_idx`(`task_id`, `participant_id`, `check_status`),
INDEX `inventory_task_item_task_id_equipment_id_idx`(`task_id`, `equipment_id`),
INDEX `inventory_task_item_task_id_is_unassigned_idx`(`task_id`, `is_unassigned`),
INDEX `inventory_task_item_task_id_check_status_idx`(`task_id`, `check_status`),
INDEX `inventory_task_item_checked_by_participant_id_idx`(`checked_by_participant_id`),
INDEX `inventory_task_item_snapshot_department_id_idx`(`snapshot_department_id`),
INDEX `inventory_task_item_snapshot_person_id_idx`(`snapshot_person_id`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_item_task_id_fkey`
FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_participant_id_fkey`
FOREIGN KEY (`participant_id`) REFERENCES `inventory_task_participant`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_checked_by_participant_id_fkey`
FOREIGN KEY (`checked_by_participant_id`) REFERENCES `inventory_task_participant`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_equipment_id_fkey`
FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_asset_type_id_fkey`
FOREIGN KEY (`asset_type_id`) REFERENCES `asset_type`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_snapshot_department_id_fkey`
FOREIGN KEY (`snapshot_department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_snapshot_person_id_fkey`
FOREIGN KEY (`snapshot_person_id`) REFERENCES `person`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `inventory_task_action` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`participant_id` INT NULL,
`item_id` INT NULL,
`action_type` VARCHAR(40) NOT NULL,
`action_payload` TEXT NULL,
`ip_address` VARCHAR(50) NULL,
`user_agent` VARCHAR(255) NULL,
`operator_user_id` INT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `inventory_task_action_task_id_created_at_idx`(`task_id`, `created_at`),
INDEX `inventory_task_action_participant_id_idx`(`participant_id`),
INDEX `inventory_task_action_item_id_idx`(`item_id`),
INDEX `inventory_task_action_operator_user_id_idx`(`operator_user_id`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_action_task_id_fkey`
FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_action_participant_id_fkey`
FOREIGN KEY (`participant_id`) REFERENCES `inventory_task_participant`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_action_item_id_fkey`
FOREIGN KEY (`item_id`) REFERENCES `inventory_task_item`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_action_operator_user_id_fkey`
FOREIGN KEY (`operator_user_id`) REFERENCES `user`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -20,7 +20,9 @@ model AssetType {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
equipments Equipment[]
equipments Equipment[]
inventoryTasks InventoryTask[]
inventoryTaskItems InventoryTaskItem[]
@@index([status])
@@index([short_name])
@ -43,9 +45,12 @@ model Department {
parent Department? @relation("DepartmentHierarchy", fields: [parent_id], references: [id])
children Department[] @relation("DepartmentHierarchy")
equipments Equipment[]
mainPersons Person[] @relation("PersonMainDepartment")
personDepartments PersonDepartment[]
equipments Equipment[]
mainPersons Person[] @relation("PersonMainDepartment")
personDepartments PersonDepartment[]
inventoryTasks InventoryTask[]
inventoryTaskParticipants InventoryTaskParticipant[]
inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotDepartment")
@@index([status])
@@index([parent_id])
@ -94,6 +99,7 @@ model Equipment {
disposalRecords DisposalRecord[]
// 维修记录关联
maintenanceRecords MaintenanceRecord[]
inventoryTaskItems InventoryTaskItem[]
@@index([equipment_code])
@@index([equipment_type])
@ -160,6 +166,8 @@ model User {
maintenance_records MaintenanceRecord[]
notifications UserNotification[]
processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy")
inventory_tasks_created InventoryTask[] @relation("InventoryTaskCreatedBy")
inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator")
@@index([role])
@@index([status])
@ -197,21 +205,27 @@ model Person {
source_system String? @db.VarChar(30)
source_main_department_id Int?
source_dept_ids String? @db.Text
source_department_name String? @db.VarChar(100)
source_main_department_name String? @db.VarChar(100)
department_sync_bucket String? @db.VarChar(20)
manager_source_userid String? @db.VarChar(100)
source_employee_status String? @db.VarChar(30)
last_synced_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
borrow_records BorrowRecord[]
mainDepartment Department? @relation("PersonMainDepartment", fields: [main_department_id], references: [id])
personDepartments PersonDepartment[]
borrow_records BorrowRecord[]
mainDepartment Department? @relation("PersonMainDepartment", fields: [main_department_id], references: [id])
personDepartments PersonDepartment[]
inventoryTaskParticipants InventoryTaskParticipant[]
inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotPerson")
@@index([nickname])
@@index([real_name])
@@index([status])
@@index([main_department_id])
@@index([source_system])
@@index([department_sync_bucket])
@@index([last_synced_at])
@@map("person")
}
@ -344,6 +358,142 @@ model UserNotification {
@@map("user_notification")
}
model InventoryTask {
id Int @id @default(autoincrement())
title String @db.VarChar(150)
description String? @db.Text
status String @default("active") @db.VarChar(20)
public_token String @unique @db.VarChar(120)
scope_department_id Int?
scope_department_name String? @db.VarChar(100)
scope_asset_type_id Int?
scope_asset_type_name String? @db.VarChar(100)
scope_snapshot_json String? @db.Text
participants_count Int @default(0)
items_count Int @default(0)
completed_count Int @default(0)
exception_count Int @default(0)
unassigned_count Int @default(0)
created_by Int
closed_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
scopeDepartment Department? @relation(fields: [scope_department_id], references: [id])
scopeAssetType AssetType? @relation(fields: [scope_asset_type_id], references: [id])
creator User @relation("InventoryTaskCreatedBy", fields: [created_by], references: [id])
participants InventoryTaskParticipant[]
items InventoryTaskItem[]
actions InventoryTaskAction[]
@@index([status, created_at])
@@index([scope_department_id, status])
@@index([scope_asset_type_id, status])
@@index([created_by])
@@map("inventory_task")
}
model InventoryTaskParticipant {
id Int @id @default(autoincrement())
task_id Int
person_id Int?
person_name String @db.VarChar(50)
person_nickname String @db.VarChar(50)
person_source_userid String? @db.VarChar(100)
department_id Int?
department_name String? @db.VarChar(100)
status String @default("pending") @db.VarChar(20)
confirmed_at DateTime?
last_action_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade)
person Person? @relation(fields: [person_id], references: [id], onDelete: SetNull)
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
assigned_items InventoryTaskItem[] @relation("InventoryTaskItemAssignee")
checked_items InventoryTaskItem[] @relation("InventoryTaskItemChecker")
actions InventoryTaskAction[]
@@unique([task_id, person_id])
@@index([task_id, status])
@@index([task_id, person_id])
@@index([task_id, person_nickname])
@@index([department_id])
@@map("inventory_task_participant")
}
model InventoryTaskItem {
id Int @id @default(autoincrement())
task_id Int
participant_id Int?
equipment_id Int
equipment_code String @db.VarChar(50)
equipment_name String @db.VarChar(100)
equipment_model String? @db.VarChar(100)
equipment_type String @db.VarChar(50)
asset_type_id Int?
asset_type_name String? @db.VarChar(100)
snapshot_department_id Int?
snapshot_department_name String? @db.VarChar(100)
snapshot_person_id Int?
snapshot_person_name String? @db.VarChar(50)
snapshot_person_nickname String? @db.VarChar(50)
snapshot_status String? @db.VarChar(20)
source_borrow_record_id Int?
is_unassigned Boolean @default(false)
check_status String @default("pending") @db.VarChar(20)
checked_by_participant_id Int?
checked_at DateTime?
exception_type String? @db.VarChar(30)
exception_note String? @db.Text
created_at DateTime @default(now())
updated_at DateTime @updatedAt
task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade)
participant InventoryTaskParticipant? @relation("InventoryTaskItemAssignee", fields: [participant_id], references: [id], onDelete: SetNull)
checked_by_participant InventoryTaskParticipant? @relation("InventoryTaskItemChecker", fields: [checked_by_participant_id], references: [id], onDelete: SetNull)
equipment Equipment @relation(fields: [equipment_id], references: [id], onDelete: Cascade)
assetType AssetType? @relation(fields: [asset_type_id], references: [id], onDelete: SetNull)
snapshotDepartment Department? @relation("InventoryTaskItemSnapshotDepartment", fields: [snapshot_department_id], references: [id], onDelete: SetNull)
snapshotPerson Person? @relation("InventoryTaskItemSnapshotPerson", fields: [snapshot_person_id], references: [id], onDelete: SetNull)
actions InventoryTaskAction[]
@@unique([task_id, equipment_id])
@@index([task_id, participant_id, check_status])
@@index([task_id, equipment_id])
@@index([task_id, is_unassigned])
@@index([task_id, check_status])
@@index([checked_by_participant_id])
@@index([snapshot_department_id])
@@index([snapshot_person_id])
@@map("inventory_task_item")
}
model InventoryTaskAction {
id Int @id @default(autoincrement())
task_id Int
participant_id Int?
item_id Int?
action_type String @db.VarChar(40)
action_payload String? @db.Text
ip_address String? @db.VarChar(50)
user_agent String? @db.VarChar(255)
operator_user_id Int?
created_at DateTime @default(now())
task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade)
participant InventoryTaskParticipant? @relation(fields: [participant_id], references: [id], onDelete: SetNull)
item InventoryTaskItem? @relation(fields: [item_id], references: [id], onDelete: SetNull)
operator User? @relation("InventoryTaskActionOperator", fields: [operator_user_id], references: [id], onDelete: SetNull)
@@index([task_id, created_at])
@@index([participant_id])
@@index([item_id])
@@index([operator_user_id])
@@map("inventory_task_action")
}
model MountHistory {
id Int @id @default(autoincrement())

View File

@ -0,0 +1,326 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const ensureOutputDir = () => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `location_backfill_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestMigrationDir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
inputDir: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
index += 1
}
}
return options
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestMigrationDir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const buildPayload = (records) => {
const importableRecords = records.filter(
(record) => record.bucket === 'import-ready' || record.bucket === 'import-without-owner'
)
const grouped = new Map()
for (const record of importableRecords) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
continue
}
if (!grouped.has(legacyCode)) {
grouped.set(legacyCode, [])
}
grouped.get(legacyCode).push(record)
}
const duplicateCodes = new Set(
[...grouped.entries()].filter(([, items]) => items.length > 1).map(([legacyCode]) => legacyCode)
)
const updates = []
for (const record of importableRecords) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode || duplicateCodes.has(legacyCode)) {
continue
}
const resolvedLocationName = normalizeText(
record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue
)
if (!resolvedLocationName || !record.locationMatch) {
continue
}
updates.push({
legacyCode,
rawLocation: normalizeText(record.addressRaw),
resolvedLocationName,
sourceRow: record.sourceRow
})
}
return updates
}
const runRemoteNodeScript = (script) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`)
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
return JSON.parse(stdout)
}
const buildRemoteScript = (payload) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
;(async () => {
const locationNames = [...new Set(payload.map((item) => item.resolvedLocationName))]
const [locations, equipmentsBefore] = await Promise.all([
prisma.storageLocation.findMany({
where: { status: 'active', name: { in: locationNames } },
select: { id: true, name: true, code: true }
}),
prisma.equipment.findMany({
where: { equipment_code: { in: payload.map((item) => item.legacyCode) } },
select: {
id: true,
equipment_code: true,
equipment_name: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
])
const locationByName = new Map(locations.map((item) => [item.name, item]))
const equipmentByCode = new Map(equipmentsBefore.map((item) => [item.equipment_code, item]))
const skipped = []
const planned = []
for (const item of payload) {
const equipment = equipmentByCode.get(item.legacyCode)
if (!equipment) {
skipped.push({ ...item, reason: 'equipment_not_found' })
continue
}
if (equipment.storage_location_id) {
skipped.push({
...item,
reason: 'already_has_storage_location',
current_storage_location: equipment.storageLocation?.name || ''
})
continue
}
const location = locationByName.get(item.resolvedLocationName)
if (!location) {
skipped.push({ ...item, reason: 'storage_location_not_found' })
continue
}
planned.push({
equipmentId: equipment.id,
legacyCode: item.legacyCode,
equipmentName: equipment.equipment_name,
rawLocation: item.rawLocation,
resolvedLocationName: item.resolvedLocationName,
storageLocationId: location.id
})
}
const updated = []
for (const item of planned) {
const result = await prisma.equipment.update({
where: { id: item.equipmentId },
data: { storage_location_id: item.storageLocationId },
select: {
id: true,
equipment_code: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
updated.push(result)
}
const verification = {
nullStorageLocationCount: await prisma.equipment.count({ where: { storage_location_id: null } }),
updatedCount: updated.length
}
console.log(JSON.stringify({
payloadCount: payload.length,
plannedCount: planned.length,
updatedCount: updated.length,
skippedCount: skipped.length,
planned,
skipped,
verification
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
if (!fs.existsSync(normalizedPath)) {
throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`)
}
const normalizedRecords = readJsonl(normalizedPath)
const payload = buildPayload(normalizedRecords)
if (!payload.length) {
throw new Error('没有可回填的地址记录')
}
const outputDir = ensureOutputDir()
writeJson(path.join(outputDir, 'planned_payload.json'), {
inputDir,
payloadCount: payload.length,
payload
})
const result = runRemoteNodeScript(buildRemoteScript(payload))
writeJson(path.join(outputDir, 'result.json'), result)
console.log(
JSON.stringify(
{
outputDir,
inputDir,
payloadCount: payload.length,
result
},
null,
2
)
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}

View File

@ -0,0 +1,439 @@
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DEFAULT_OUTPUT = path.resolve(__dirname, '../../行政手动补录清单.xlsx')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
inputDir: '',
importBackupDir: '',
output: DEFAULT_OUTPUT
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
index += 1
} else if (arg === '--import-backup-dir') {
options.importBackupDir = args[index + 1] || ''
index += 1
} else if (arg === '--output') {
options.output = args[index + 1] || DEFAULT_OUTPUT
index += 1
}
}
return options
}
const resolveLatestDir = (prefix) => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到目录前缀 ${prefix} 的输出结果`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolvePathArg = (value, fallbackPrefix) => {
if (!value) {
return resolveLatestDir(fallbackPrefix)
}
const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value)
if (!fs.existsSync(resolved)) {
throw new Error(`路径不存在: ${resolved}`)
}
return resolved
}
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const parseDescription = (description) => {
const result = {}
const normalized = normalizeText(description)
if (!normalized) {
return result
}
for (const part of normalized.split(';')) {
const trimmed = normalizeText(part)
if (!trimmed) {
continue
}
const splitIndex = trimmed.indexOf('=')
if (splitIndex === -1) {
continue
}
const key = normalizeText(trimmed.slice(0, splitIndex))
const value = normalizeText(trimmed.slice(splitIndex + 1))
if (key) {
result[key] = value
}
}
return result
}
const withEditableFields = (row, fields) => ({
...row,
...fields
})
const buildPendingRows = (records) =>
records
.filter((record) => record.bucket === 'pending-review')
.map((record) =>
withEditableFields(
{
source_row: record.sourceRow,
legacy_asset_code: normalizeText(record.assetCodeRaw),
asset_name: normalizeText(record.assetNameRaw),
source_status: normalizeText(record.currentStatusRaw),
raw_owner_name: normalizeText(record.ownerRaw),
raw_department: normalizeText(record.departmentRaw),
normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
raw_location: normalizeText(record.addressRaw),
pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '',
suggested_action:
Array.isArray(record.pendingReasons) && record.pendingReasons.includes('unmapped_department')
? '先确认业务部门映射'
: '人工核对后再补录'
},
{
行政填写_目标部门: '',
行政填写_目标员工: '',
行政填写_标准地址: '',
行政填写_资产状态: '',
行政填写_备注: ''
}
)
)
const flattenDuplicateRows = (duplicateDetails) =>
duplicateDetails.flatMap((item) =>
item.items.map((detail, index) => ({
legacy_code: item.legacyCode,
conflict_index: index + 1,
bucket: detail.bucket,
source_row: detail.sourceRow,
asset_name: detail.assetName,
owner: detail.owner,
department: detail.department,
source_status: detail.sourceStatus,
address: detail.address,
unit_price: detail.unitPrice,
purchase_date: detail.purchaseDate,
suggested_action: '同一个资产编码对应了多条记录,先人工确认保留哪一条'
}))
)
const runRemoteNodeScript = (script) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
{
encoding: 'utf8',
input: script,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`)
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
return JSON.parse(stdout)
}
const buildRemoteNullLocationScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const rows = await prisma.equipment.findMany({
where: { storage_location_id: null },
orderBy: [{ status: 'asc' }, { equipment_code: 'asc' }],
select: {
equipment_code: true,
equipment_name: true,
equipment_type: true,
status: true,
unit_price: true,
purchase_date: true,
description: true,
department: { select: { name: true } }
}
})
process.stdout.write(JSON.stringify(rows))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildOwnerManualRows = (records, duplicateCodeSet) =>
records
.filter(
(record) =>
record.bucket === 'import-without-owner' &&
record.statusKind === 'assigned' &&
normalizeText(record.assetCodeRaw) &&
!duplicateCodeSet.has(normalizeText(record.assetCodeRaw).toUpperCase())
)
.map((record) =>
withEditableFields(
{
source_row: record.sourceRow,
legacy_asset_code: normalizeText(record.assetCodeRaw).toUpperCase(),
asset_name: normalizeText(record.assetNameRaw),
source_status: normalizeText(record.currentStatusRaw),
raw_owner_name: normalizeText(record.ownerRaw),
raw_department: normalizeText(record.departmentRaw),
normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
raw_location: normalizeText(record.addressRaw),
pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '',
suggested_action: '资产已导入,但当前使用人还没落到系统里;请行政核对后补登记使用人'
},
{
行政填写_目标员工: '',
行政填写_备注: ''
}
)
)
const buildOwnerAliasSummaryRows = (records, duplicateCodeSet) => {
const grouped = new Map()
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
const ownerName = normalizeText(record.ownerRaw)
if (
record.bucket !== 'import-without-owner' ||
record.statusKind !== 'assigned' ||
!legacyCode ||
duplicateCodeSet.has(legacyCode) ||
!ownerName
) {
continue
}
const key = ownerName
if (!grouped.has(key)) {
grouped.set(key, {
owner_name: ownerName,
asset_count: 0,
departments: new Set(),
pending_reasons: new Set(),
asset_codes: []
})
}
const current = grouped.get(key)
current.asset_count += 1
current.departments.add(normalizeText(record.departmentRaw))
for (const reason of Array.isArray(record.pendingReasons) ? record.pendingReasons : []) {
current.pending_reasons.add(reason)
}
current.asset_codes.push(legacyCode)
}
return [...grouped.values()]
.map((item) =>
withEditableFields(
{
owner_name: item.owner_name,
asset_count: item.asset_count,
departments: [...item.departments].filter(Boolean).join('、'),
pending_reasons: [...item.pending_reasons].filter(Boolean).join('|'),
sample_asset_codes: item.asset_codes.slice(0, 10).join('、'),
suggested_action: '先核对该花名对应的当前在职员工,再统一补挂名下资产'
},
{
行政填写_目标员工: '',
行政填写_备注: ''
}
)
)
.sort((left, right) => right.asset_count - left.asset_count || left.owner_name.localeCompare(right.owner_name, 'zh-CN'))
}
const buildNullLocationRows = (rows) =>
rows.map((row) => {
const metadata = parseDescription(row.description)
return withEditableFields(
{
source_row: metadata.source_row || '',
legacy_asset_code: normalizeText(row.equipment_code),
asset_name: normalizeText(row.equipment_name),
equipment_type: normalizeText(row.equipment_type),
current_status: normalizeText(row.status),
current_department: normalizeText(row.department?.name || ''),
raw_department: metadata.raw_department || '',
raw_owner: metadata.raw_owner || '',
raw_location: metadata.raw_location || '',
purchase_date: row.purchase_date ? String(row.purchase_date).slice(0, 10) : '',
unit_price: normalizeText(row.unit_price),
suggested_action: '资产已导入,但没有标准地址;请行政确认后补齐标准存放地点'
},
{
行政填写_标准地址: '',
行政填写_备注: ''
}
)
})
const appendSheet = (workbook, name, rows) => {
const safeRows = rows.length ? rows : [{ message: '当前没有数据' }]
const sheet = XLSX.utils.json_to_sheet(safeRows)
XLSX.utils.book_append_sheet(workbook, sheet, name)
}
const writeWorkbookWithFallback = (workbook, outputPath) => {
try {
XLSX.writeFile(workbook, outputPath)
return outputPath
} catch (error) {
if (error && error.code === 'EBUSY') {
const parsed = path.parse(outputPath)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const fallbackPath = path.join(parsed.dir, `${parsed.name}-${timestamp}${parsed.ext}`)
XLSX.writeFile(workbook, fallbackPath)
return fallbackPath
}
throw error
}
}
const run = () => {
const options = parseArgs()
const inputDir = resolvePathArg(options.inputDir, 'migration_dry_run_')
const importBackupDir = resolvePathArg(options.importBackupDir, 'live_import_backup_')
const outputPath = path.isAbsolute(options.output)
? options.output
: path.resolve(process.cwd(), options.output)
const normalizedRecords = readJsonl(path.join(inputDir, 'normalized_assets.jsonl'))
const missingCode = readJson(path.join(importBackupDir, 'excluded_missing_code_rows.json'))
const duplicateCodes = readJson(path.join(importBackupDir, 'excluded_duplicate_codes.json'))
const plannedSummary = readJson(path.join(importBackupDir, 'planned_import_summary.json'))
const liveNullLocationRows = runRemoteNodeScript(buildRemoteNullLocationScript())
const duplicateCodeSet = new Set(duplicateCodes.duplicateDetails.map((item) => item.legacyCode))
const pendingRows = buildPendingRows(normalizedRecords)
const ownerManualRows = buildOwnerManualRows(normalizedRecords, duplicateCodeSet)
const ownerAliasSummaryRows = buildOwnerAliasSummaryRows(normalizedRecords, duplicateCodeSet)
const flattenedDuplicateRows = flattenDuplicateRows(duplicateCodes.duplicateDetails)
const nullLocationRows = buildNullLocationRows(liveNullLocationRows)
const workbook = XLSX.utils.book_new()
appendSheet(workbook, '概览', [
{ 项目: 'dry-run 待人工确认', 数量: pendingRows.length },
{ 项目: '缺少资产编码', 数量: missingCode.excludedMissingCodeCount || 0 },
{ 项目: '重复资产编码(编码数)', 数量: duplicateCodes.excludedDuplicateCodeCount || 0 },
{ 项目: '重复资产编码(记录数)', 数量: duplicateCodes.excludedDuplicateRecordCount || 0 },
{ 项目: '已导入待补使用人', 数量: ownerManualRows.length },
{ 项目: '待核对历史花名', 数量: ownerAliasSummaryRows.length },
{ 项目: 'live 待补标准地址', 数量: nullLocationRows.length },
{ 项目: '本轮已导入总数', 数量: plannedSummary.finalImportCount || 0 },
{ 项目: '其中已导入不带使用人', 数量: plannedSummary.finalImportWithoutOwnerCount || 0 }
])
appendSheet(workbook, '填写说明', [
{ 工作表: '待确认部门', 说明: '只在“行政填写_目标部门 / 目标员工 / 标准地址 / 资产状态”里填写需要修正的值。' },
{ 工作表: '待补使用人', 说明: '在“行政填写_目标员工”里填写当前在职员工姓名或花名。' },
{ 工作表: '花名核对', 说明: '同一个历史花名如果能统一对应到当前员工,优先在这里填写。' },
{ 工作表: '待补地址', 说明: '在“行政填写_标准地址”里填写系统里的标准地址名。' },
{ 工作表: '输出用途', 说明: '填写完成后可生成 migration_manual_overrides.json,再重新 dry-run / 导入。' }
])
appendSheet(workbook, '待确认部门', pendingRows)
appendSheet(workbook, '缺少编码', missingCode.rows || [])
appendSheet(workbook, '重复编码', flattenedDuplicateRows)
appendSheet(workbook, '待补使用人', ownerManualRows)
appendSheet(workbook, '花名核对', ownerAliasSummaryRows)
appendSheet(workbook, '待补地址', nullLocationRows)
const finalOutputPath = writeWorkbookWithFallback(workbook, outputPath)
console.log(
JSON.stringify(
{
requestedOutputPath: outputPath,
outputPath: finalOutputPath,
summary: {
pendingReview: pendingRows.length,
missingCode: missingCode.excludedMissingCodeCount || 0,
duplicateCodeCount: duplicateCodes.excludedDuplicateCodeCount || 0,
duplicateRecordCount: duplicateCodes.excludedDuplicateRecordCount || 0,
ownerManual: ownerManualRows.length,
ownerAliasSummary: ownerAliasSummaryRows.length,
nullLocation: nullLocationRows.length
}
},
null,
2
)
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}

View File

@ -0,0 +1,771 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
inputDir: '',
keepExisting: false,
includeOwnerless: false
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
index += 1
} else if (arg === '--keep-existing') {
options.keepExisting = true
} else if (arg === '--include-ownerless') {
options.includeOwnerless = true
}
}
return options
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestMigrationDir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestMigrationDir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const parseDate = (value) => {
const normalized = normalizeText(value)
if (!normalized || normalized === '#REF!') {
return null
}
const directDate = new Date(normalized)
if (!Number.isNaN(directDate.getTime())) {
return directDate.toISOString()
}
return null
}
const buildDescription = (record) => {
const parts = [
['legacy_asset_code', record.assetCodeRaw || ''],
['source_row', record.sourceRow],
['source_status', record.currentStatusRaw || ''],
['raw_owner', record.ownerRaw || ''],
['raw_department', record.departmentRaw || ''],
['raw_location', record.addressRaw || ''],
['raw_category', record.assetCategoryRaw || '']
]
return parts
.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`)
.join('; ')
}
const collectDuplicateCodeDetails = (records) => {
const grouped = new Map()
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
continue
}
if (!grouped.has(legacyCode)) {
grouped.set(legacyCode, [])
}
grouped.get(legacyCode).push({
bucket: normalizeText(record.bucket),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
resolvedType: normalizeText(record.assetTypeResolvedName || record.assetTypeName),
address: normalizeText(record.addressRaw),
purchaseDate: normalizeText(record.purchaseDateParsed),
unitPrice: normalizeText(record.unitPriceParsed),
sourceStatus: normalizeText(record.currentStatusRaw),
statusKind: normalizeText(record.statusKind),
pendingReasons: Array.isArray(record.pendingReasons) ? record.pendingReasons : []
})
}
return [...grouped.entries()]
.filter(([, items]) => items.length > 1)
.map(([legacyCode, items]) => ({ legacyCode, items }))
}
const partitionImportCandidates = (records) => {
const missingCodeRows = []
const validCodeRecords = []
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
missingCodeRows.push({
bucket: normalizeText(record.bucket),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
currentStatus: normalizeText(record.currentStatusRaw),
address: normalizeText(record.addressRaw)
})
continue
}
validCodeRecords.push(record)
}
const duplicateDetails = collectDuplicateCodeDetails(validCodeRecords)
const duplicateCodes = new Set(duplicateDetails.map((item) => item.legacyCode))
const filteredRecords = validCodeRecords.filter(
(record) => !duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase())
)
const duplicateRows = validCodeRecords
.filter((record) => duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase()))
.map((record) => ({
bucket: normalizeText(record.bucket),
legacyCode: normalizeText(record.assetCodeRaw).toUpperCase(),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
currentStatus: normalizeText(record.currentStatusRaw)
}))
return {
filteredRecords,
missingCodeRows,
duplicateRows,
duplicateDetails
}
}
const buildImportPayload = (records) => {
const payload = []
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
const importMode = record.bucket === 'import-without-owner' ? 'without_owner' : 'with_owner'
const statusKind = normalizeText(record.statusKind)
const pendingReasons = Array.isArray(record.pendingReasons) ? record.pendingReasons : []
const matchedPerson = record.ownerMatch?.matchedPerson || null
if (importMode === 'with_owner' && !matchedPerson?.id) {
throw new Error(`发现未匹配 owner 的 import-ready 记录,sourceRow=${record.sourceRow} code=${legacyCode}`)
}
if (importMode === 'without_owner' && !['assigned', 'unassigned'].includes(statusKind)) {
throw new Error(
`import-without-owner 记录状态异常,无法安全导入,sourceRow=${record.sourceRow} code=${legacyCode}`
)
}
const descriptionExtras = [`migration_bucket=${record.bucket}`]
if (importMode === 'without_owner') {
descriptionExtras.push(`owner_pending_reasons=${pendingReasons.join('|') || 'none'}`)
descriptionExtras.push(`owner_resolution=manual_followup_required`)
}
payload.push({
legacyCode,
importMode,
sourceStatusKind: statusKind,
unresolvedOwnerReasons: pendingReasons.join('|'),
rawOwnerName: normalizeText(record.ownerRaw),
equipmentName: normalizeText(record.assetNameRaw),
equipmentModel: normalizeText(record.equipment_model || record.modelRaw) || null,
equipmentType: normalizeText(record.assetTypeResolvedName || record.assetTypeName),
departmentName: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
storageLocationName: record.locationMatch
? normalizeText(record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue)
: '',
unitPrice: Number(record.unitPriceParsed || 0),
purchaseDate: normalizeText(record.purchaseDateParsed) || null,
supplier: '',
description: [buildDescription(record), ...descriptionExtras].join('; '),
ownerPersonId: matchedPerson?.id || null,
ownerNickname: normalizeText(matchedPerson?.nickname || ''),
ownerRealName: normalizeText(matchedPerson?.real_name || ''),
borrowTime: parseDate(record.claimedAtRaw) || parseDate(record.purchaseDateParsed) || null,
sourceRow: record.sourceRow
})
}
return payload
}
const runRemoteNodeScript = (script) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`)
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
return JSON.parse(stdout)
}
const buildRemoteBackupScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const [
equipment,
borrowRecords,
maintenanceRecords,
disposalRecords,
mountHistories,
inventoryTasks,
inventoryTaskParticipants,
inventoryTaskItems,
inventoryTaskActions
] = await Promise.all([
prisma.equipment.findMany({ orderBy: { id: 'asc' } }),
prisma.borrowRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.maintenanceRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.disposalRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.mountHistory.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTask.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskParticipant.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskItem.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskAction.findMany({ orderBy: { id: 'asc' } })
])
console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
counts: {
equipment: equipment.length,
borrowRecords: borrowRecords.length,
maintenanceRecords: maintenanceRecords.length,
disposalRecords: disposalRecords.length,
mountHistories: mountHistories.length,
inventoryTasks: inventoryTasks.length,
inventoryTaskParticipants: inventoryTaskParticipants.length,
inventoryTaskItems: inventoryTaskItems.length,
inventoryTaskActions: inventoryTaskActions.length
},
equipment,
borrowRecords,
maintenanceRecords,
disposalRecords,
mountHistories,
inventoryTasks,
inventoryTaskParticipants,
inventoryTaskItems,
inventoryTaskActions
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildRemoteWipeScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const before = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
maintenanceRecords: await prisma.maintenanceRecord.count(),
disposalRecords: await prisma.disposalRecord.count(),
mountHistories: await prisma.mountHistory.count(),
inventoryTasks: await prisma.inventoryTask.count(),
inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(),
inventoryTaskItems: await prisma.inventoryTaskItem.count(),
inventoryTaskActions: await prisma.inventoryTaskAction.count()
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTaskAction.deleteMany({})
await tx.inventoryTaskItem.deleteMany({})
await tx.inventoryTaskParticipant.deleteMany({})
await tx.inventoryTask.deleteMany({})
await tx.maintenanceRecord.deleteMany({})
await tx.disposalRecord.deleteMany({})
await tx.mountHistory.deleteMany({})
await tx.borrowRecord.deleteMany({})
await tx.equipment.deleteMany({})
})
const after = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
maintenanceRecords: await prisma.maintenanceRecord.count(),
disposalRecords: await prisma.disposalRecord.count(),
mountHistories: await prisma.mountHistory.count(),
inventoryTasks: await prisma.inventoryTask.count(),
inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(),
inventoryTaskItems: await prisma.inventoryTaskItem.count(),
inventoryTaskActions: await prisma.inventoryTaskAction.count()
}
console.log(JSON.stringify({ before, after }))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildRemoteImportScript = (payload, keepExisting) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const keepExisting = ${keepExisting ? 'true' : 'false'}
const normalizeText = (value) => String(value == null ? '' : value).replace(/\\r/g, ' ').replace(/\\n/g, ' ').replace(/\\s+/g, ' ').trim()
const toDateOrNull = (value) => {
const normalized = normalizeText(value)
if (!normalized) return null
const parsed = new Date(normalized)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const calculateValues = (unitPrice, remainingQuantity) => {
const normalizedUnitPrice = Number(unitPrice || 0)
const totalValue = normalizedUnitPrice
const borrowedQuantity = Math.max(1 - Number(remainingQuantity || 0), 0)
return {
totalValue,
borrowedValue: normalizedUnitPrice * borrowedQuantity,
availableValue: normalizedUnitPrice * Number(remainingQuantity || 0)
}
}
;(async () => {
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({ where: { status: 'active' }, select: { id: true, name: true } }),
prisma.department.findMany({
where: { status: 'active', source_system: 'ding_hr_business' },
select: { id: true, name: true }
}),
prisma.storageLocation.findMany({ where: { status: 'active' }, select: { id: true, name: true } })
])
const assetTypeMap = new Map(assetTypes.map((item) => [item.name, item]))
const departmentMap = new Map(departments.map((item) => [item.name, item]))
const locationMap = new Map(locations.map((item) => [item.name, item]))
const duplicatePayloadCodes = new Set()
const payloadCodes = new Set()
for (const item of payload) {
if (payloadCodes.has(item.legacyCode)) {
duplicatePayloadCodes.add(item.legacyCode)
}
payloadCodes.add(item.legacyCode)
}
if (duplicatePayloadCodes.size > 0) {
throw new Error('导入 payload 中存在重复资产编码: ' + [...duplicatePayloadCodes].join(', '))
}
const existingCodes = await prisma.equipment.findMany({
where: { equipment_code: { in: [...payloadCodes] } },
select: { equipment_code: true }
})
if (!keepExisting && existingCodes.length > 0) {
throw new Error('线上已存在同名资产编码: ' + existingCodes.map((item) => item.equipment_code).join(', '))
}
const existingCodeSet = new Set(existingCodes.map((item) => item.equipment_code))
const results = []
const skippedExisting = []
for (const item of payload) {
if (existingCodeSet.has(item.legacyCode)) {
skippedExisting.push({
legacyCode: item.legacyCode,
importMode: item.importMode,
reason: 'already_exists'
})
continue
}
const assetType = assetTypeMap.get(item.equipmentType)
if (!assetType) {
throw new Error('资产类型不存在: ' + item.equipmentType + ' code=' + item.legacyCode)
}
const department = item.departmentName ? departmentMap.get(item.departmentName) || null : null
if (item.departmentName && !department) {
throw new Error('业务部门不存在: ' + item.departmentName + ' code=' + item.legacyCode)
}
const location = item.storageLocationName ? locationMap.get(item.storageLocationName) || null : null
if (item.storageLocationName && !location) {
throw new Error('存放地点不存在: ' + item.storageLocationName + ' code=' + item.legacyCode)
}
let person = null
if (item.importMode === 'with_owner') {
person = await prisma.person.findFirst({
where: {
id: item.ownerPersonId,
status: 'active',
source_system: 'ding_hr'
},
select: {
id: true,
real_name: true,
nickname: true,
id_number: true
}
})
if (!person) {
throw new Error('使用人不存在或不再是 active: personId=' + item.ownerPersonId + ' code=' + item.legacyCode)
}
}
const unitPrice = Number(item.unitPrice || 0)
const purchaseDate = toDateOrNull(item.purchaseDate)
const borrowTime = toDateOrNull(item.borrowTime) || new Date()
let equipmentStatus = '可用'
let remainingQuantity = 1
let borrowRecordNeeded = false
let borrowNotes = 'Excel迁移导入;在职长期'
if (item.importMode === 'with_owner') {
equipmentStatus = '已借出'
remainingQuantity = 0
borrowRecordNeeded = true
} else if (item.sourceStatusKind === 'assigned') {
equipmentStatus = '已借出'
remainingQuantity = 0
borrowNotes = 'Excel迁移导入;当前使用人待行政补录'
} else if (item.sourceStatusKind === 'unassigned') {
equipmentStatus = '可用'
remainingQuantity = 1
} else {
throw new Error('ownerless 资产状态异常: ' + item.sourceStatusKind + ' code=' + item.legacyCode)
}
const values = calculateValues(unitPrice, remainingQuantity)
const created = await prisma.$transaction(async (tx) => {
const equipment = await tx.equipment.create({
data: {
equipment_code: item.legacyCode,
equipment_name: item.equipmentName,
equipment_model: normalizeText(item.equipmentModel) || null,
equipment_type: assetType.name,
code_generation_mode: 'manual',
status: equipmentStatus,
total_quantity: 1,
remaining_quantity: remainingQuantity,
unit_price: unitPrice,
total_value: values.totalValue,
borrowed_value: values.borrowedValue,
available_value: values.availableValue,
total_value_with_children: values.totalValue,
description: normalizeText(item.description) || null,
purchase_date: purchaseDate,
supplier: normalizeText(item.supplier) || null,
storage_location_id: location?.id,
asset_type_id: assetType.id,
department_id: department?.id,
mount_status: 'none'
}
})
if (borrowRecordNeeded && person) {
await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: borrowNotes
}
})
}
return {
id: equipment.id,
equipment_code: equipment.equipment_code,
importMode: item.importMode,
status: equipment.status,
person_id: person?.id || null,
person_nickname: person?.nickname || null
}
})
results.push(created)
}
const verification = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
activeBorrow: await prisma.borrowRecord.count({ where: { status: '已借出' } }),
importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: [...payloadCodes] } } }),
importedWithOwnerCount: results.filter((item) => item.importMode === 'with_owner').length,
importedWithoutOwnerCount: results.filter((item) => item.importMode === 'without_owner').length,
skippedExistingCount: skippedExisting.length
}
console.log(JSON.stringify({
imported: results.length,
verification,
skippedExisting,
sample: results.slice(0, 10)
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const buildRemoteVerifyScript = (payload) => {
const payloadBase64 = Buffer.from(
JSON.stringify(payload.map((item) => item.legacyCode)),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const codes = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
;(async () => {
const [equipment, borrowRecords, activeBorrow, importedEquipments] = await Promise.all([
prisma.equipment.count(),
prisma.borrowRecord.count(),
prisma.borrowRecord.count({ where: { status: '已借出' } }),
prisma.equipment.findMany({
where: { equipment_code: { in: codes } },
orderBy: { equipment_code: 'asc' },
take: 20,
select: {
equipment_code: true,
equipment_name: true,
equipment_type: true,
status: true,
remaining_quantity: true,
department: { select: { name: true } },
storageLocation: { select: { name: true } }
}
})
])
console.log(JSON.stringify({
equipment,
borrowRecords,
activeBorrow,
importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: codes } } }),
sample: importedEquipments
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
if (!fs.existsSync(normalizedPath)) {
throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`)
}
const normalizedRecords = readJsonl(normalizedPath)
const importReadyRecords = normalizedRecords.filter((record) => record.bucket === 'import-ready')
const importWithoutOwnerRecords = options.includeOwnerless
? normalizedRecords.filter((record) => record.bucket === 'import-without-owner')
: []
const importCandidates = [...importReadyRecords, ...importWithoutOwnerRecords]
if (!importCandidates.length) {
throw new Error('没有可导入的确认记录')
}
const partitioned = partitionImportCandidates(importCandidates)
const payload = buildImportPayload(partitioned.filteredRecords)
const backupDir = ensureOutputDir('live_import_backup')
writeJson(path.join(backupDir, 'excluded_missing_code_rows.json'), {
excludedMissingCodeCount: partitioned.missingCodeRows.length,
rows: partitioned.missingCodeRows
})
writeJson(path.join(backupDir, 'excluded_duplicate_codes.json'), {
excludedDuplicateCodeCount: partitioned.duplicateDetails.length,
excludedDuplicateRecordCount: partitioned.duplicateRows.length,
duplicateDetails: partitioned.duplicateDetails,
excludedRows: partitioned.duplicateRows
})
writeJson(path.join(backupDir, 'planned_import_summary.json'), {
inputDir,
importReadyCount: importReadyRecords.length,
importWithoutOwnerCount: importWithoutOwnerRecords.length,
selectedCandidateCount: importCandidates.length,
excludedMissingCodeCount: partitioned.missingCodeRows.length,
excludedDuplicateRecordCount: partitioned.duplicateRows.length,
excludedDuplicateCodeCount: partitioned.duplicateDetails.length,
finalImportCount: payload.length,
finalImportReadyCount: payload.filter((item) => item.importMode === 'with_owner').length,
finalImportWithoutOwnerCount: payload.filter((item) => item.importMode === 'without_owner').length,
keepExisting: options.keepExisting,
includeOwnerless: options.includeOwnerless
})
writeJson(path.join(backupDir, 'planned_payload.json'), {
payloadCount: payload.length,
payload
})
const backup = runRemoteNodeScript(buildRemoteBackupScript())
writeJson(path.join(backupDir, 'pre_import_live_backup.json'), backup)
let wipeResult = { skipped: true }
if (!options.keepExisting) {
wipeResult = runRemoteNodeScript(buildRemoteWipeScript())
writeJson(path.join(backupDir, 'wipe_result.json'), wipeResult)
}
const importResult = runRemoteNodeScript(buildRemoteImportScript(payload, options.keepExisting))
writeJson(path.join(backupDir, 'import_result.json'), importResult)
const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload))
writeJson(path.join(backupDir, 'post_import_verification.json'), verification)
console.log(
JSON.stringify(
{
backupDir,
inputDir,
importedCount: importResult.imported,
wipeResult,
importResult,
verification
},
null,
2
)
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}

View File

@ -0,0 +1,133 @@
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const STORAGE_LOCATION_ALIASES = {
天安: '常州-天安',
天安平面: '常州-天安',
天安3D: '常州-天安',
天安二楼分销: '常州-天安',
二楼分销: '常州-天安',
天安策划: '常州-天安',
天安二楼: '常州-天安',
天安直播间: '常州-天安',
天安东直播间: '常州-天安',
天安东南直播间: '常州-天安',
天安东小直播间: '常州-天安',
'天安-大龙虾': '常州-天安',
天安库存: '常州-天安',
天安数据机: '常州-天安',
天安3楼: '常州-天安',
天安3楼机房: '常州-天安',
天安影棚: '常州-天安',
天安三楼经管胖大海: '常州-天安',
影棚: '常州-天安',
摄影棚: '常州-天安',
摄影中心: '常州-天安',
摄影中心监控: '常州-天安',
二楼外贸: '常州-天安',
横林: '常州-仓库',
横林仓: '常州-仓库',
横林二仓: '常州-仓库',
横林客退: '常州-仓库',
客退: '常州-仓库',
常州临时仓: '常州-仓库',
崔桥工厂仓: '常州-仓库',
崔桥仓: '常州-仓库',
欧伯娜崔桥工厂: '常州-仓库',
欧伯娜工厂: '常州-仓库',
崔桥工厂欧伯娜: '常州-仓库',
新苏北仓: '常州-仓库',
常州: '常州-天安',
杭州: '杭州-万通中心',
杭州宋达: '杭州-万通中心',
南京: '南京-苏宁园区',
南京运营网盘: '南京-苏宁园区',
南京机房远程数据机: '南京-苏宁园区',
'10号楼1楼机房监控': '南京-苏宁园区',
'10号楼1楼监控室': '南京-苏宁园区',
'10号楼1楼配电间监控': '南京-苏宁园区',
'10号楼3楼机房': '南京-苏宁园区',
佛山3D: '佛山',
佛山平面: '佛山',
佛山建模: '佛山',
广东佛山: '佛山',
顺德仓: '佛山'
}
const resolveStorageLocationName = (rawValue) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: normalized,
normalizedValue: '',
rule: ''
}
}
const aliased = STORAGE_LOCATION_ALIASES[normalized]
if (aliased) {
return {
rawValue: normalized,
normalizedValue: aliased,
rule: 'alias'
}
}
return {
rawValue: normalized,
normalizedValue: normalized,
rule: 'direct'
}
}
const resolveStorageLocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: '',
normalizedValue: '',
match: null,
rule: ''
}
}
const directMatch = locationByName.get(normalizeLookupKey(normalized)) || null
if (directMatch) {
return {
rawValue: normalized,
normalizedValue: directMatch.name,
match: directMatch,
rule: 'direct'
}
}
const resolved = resolveStorageLocationName(rawValue)
if (!resolved.normalizedValue) {
return {
rawValue: normalized,
normalizedValue: '',
match: null,
rule: ''
}
}
const aliasMatch = locationByName.get(normalizeLookupKey(resolved.normalizedValue)) || null
return {
rawValue: normalized,
normalizedValue: resolved.normalizedValue,
match: aliasMatch,
rule: resolved.rule
}
}
module.exports = {
STORAGE_LOCATION_ALIASES,
resolveStorageLocationName,
resolveStorageLocationMatch
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
const longTerm = Boolean(isLongTerm)
@ -309,8 +310,14 @@ export const batchTransfer = async (req: Request, res: Response) => {
return errorResponse(res, '请选择要转移的资产')
}
const department = await prisma.department.findUnique({ where: { id: Number(department_id) } })
if (!department || department.status !== 'active') {
const department = await prisma.department.findFirst({
where: {
id: Number(department_id),
status: 'active',
...buildSyncedBusinessDepartmentWhere()
}
})
if (!department) {
return errorResponse(res, '目标部门不存在或已停用')
}

View File

@ -3,6 +3,7 @@ import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { generateEquipmentCode } from '../utils/equipmentCode'
import { calculateEquipmentValues } from '../utils/assetValue'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
interface PersonImportData {
nickname: string
@ -43,7 +44,9 @@ export const batchImportPersons = async (req: Request, res: Response) => {
}
const activeDepartments = await prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
},
select: { id: true, name: true }
})
const departmentMap = new Map(activeDepartments.map((department) => [department.name, department.id]))
@ -141,7 +144,11 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
// 预加载资产类型、部门、地址映射
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({ where: { status: 'active' } }),
prisma.department.findMany({ where: { status: 'active' } }),
prisma.department.findMany({
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
}
}),
prisma.storageLocation.findMany({ where: { status: 'active' } })
])

View File

@ -1,6 +1,7 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { buildBusinessDepartmentWhere } from '../utils/organization'
const baseSelect = {
id: true,
@ -20,6 +21,46 @@ const departmentCollator = new Intl.Collator('zh-CN', {
sensitivity: 'base'
})
const personCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base'
})
const resolveBusinessDepartmentIdsForPerson = (
person: {
main_department_id: number | null
personDepartments: Array<{
department: {
id: number
} | null
}>
},
departmentIdSet: Set<number>
) => {
const ids = new Set<number>()
if (person.main_department_id && departmentIdSet.has(person.main_department_id)) {
ids.add(person.main_department_id)
}
person.personDepartments.forEach((item) => {
if (item.department && departmentIdSet.has(item.department.id)) {
ids.add(item.department.id)
}
})
return [...ids]
}
const hasAnyLinkedDepartment = (person: {
main_department_id: number | null
personDepartments: Array<{
department: {
id: number
} | null
}>
}) => Boolean(person.main_department_id) || person.personDepartments.some((item) => Boolean(item.department))
const buildDepartmentLevelMap = (departments: Array<{ id: number; parent_id: number | null }>) => {
const byId = new Map(departments.map((dept) => [dept.id, dept]))
const cache = new Map<number, number>()
@ -113,11 +154,247 @@ const buildDepartmentTree = (
return (childrenMap.get(null) || []).map(buildNode)
}
const buildOrganizationTree = (
departments: Array<{
id: number
name: string
status: string
parent_id: number | null
source_system?: string | null
created_at: Date
updated_at: Date
last_synced_at?: Date | null
source_group_id?: number | null
source_parent_group_id?: number | null
_count: { equipments: number; children: number; personDepartments: number }
}>,
people: Array<{
id: number
nickname: string
real_name: string
id_number: string | null
department: string | null
main_department_id: number | null
position: string | null
phone: string | null
email: string | null
status: string
remarks: string | null
source_userid: string | null
source_system: string | null
source_main_department_id: number | null
source_dept_ids: string | null
source_department_name: string | null
source_main_department_name: string | null
department_sync_bucket: string | null
manager_source_userid: string | null
source_employee_status: string | null
last_synced_at: Date | null
created_at: Date
updated_at: Date
mainDepartment: {
id: number
name: string
status: string
parent_id: number | null
source_group_id: number | null
source_system: string | null
last_synced_at: Date | null
} | null
personDepartments: Array<{
department: {
id: number
name: string
status: string
parent_id: number | null
source_group_id: number | null
source_system: string | null
last_synced_at: Date | null
} | null
}>
}>
) => {
const compareDepartments = (
a: typeof departments[number],
b: typeof departments[number]
) => {
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1
}
const aHasChildren = a._count.children > 0
const bHasChildren = b._count.children > 0
if (aHasChildren !== bHasChildren) {
return aHasChildren ? -1 : 1
}
const aInUse = a._count.personDepartments > 0 || a._count.equipments > 0
const bInUse = b._count.personDepartments > 0 || b._count.equipments > 0
if (aInUse !== bInUse) {
return aInUse ? -1 : 1
}
const aSynced = Boolean(a.source_system)
const bSynced = Boolean(b.source_system)
if (aSynced !== bSynced) {
return aSynced ? -1 : 1
}
if (a._count.personDepartments !== b._count.personDepartments) {
return b._count.personDepartments - a._count.personDepartments
}
if (a._count.equipments !== b._count.equipments) {
return b._count.equipments - a._count.equipments
}
return departmentCollator.compare(a.name, b.name)
}
const comparePeople = (a: typeof people[number], b: typeof people[number]) => {
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1
}
const aSynced = Boolean(a.source_system)
const bSynced = Boolean(b.source_system)
if (aSynced !== bSynced) {
return aSynced ? -1 : 1
}
return personCollator.compare(`${a.nickname} ${a.real_name}`, `${b.nickname} ${b.real_name}`)
}
const childrenMap = new Map<number | null, typeof departments>()
for (const department of departments) {
const siblings = childrenMap.get(department.parent_id) || []
siblings.push(department)
childrenMap.set(department.parent_id, siblings)
}
childrenMap.forEach((siblings, key) => {
childrenMap.set(key, [...siblings].sort(compareDepartments))
})
const departmentIdSet = new Set(departments.map((department) => department.id))
const peopleByDepartmentId = new Map<number, typeof people>()
const unassignedPeople: typeof people = []
for (const person of people) {
const departmentIds = resolveBusinessDepartmentIdsForPerson(person, departmentIdSet)
if (departmentIds.length === 0) {
if (!hasAnyLinkedDepartment(person)) {
unassignedPeople.push(person)
}
continue
}
departmentIds.forEach((departmentId) => {
const bucket = peopleByDepartmentId.get(departmentId) || []
bucket.push(person)
peopleByDepartmentId.set(departmentId, bucket)
})
}
peopleByDepartmentId.forEach((bucket, departmentId) => {
peopleByDepartmentId.set(departmentId, [...bucket].sort(comparePeople))
})
const serializeOrganizationPerson = (person: typeof people[number]) => {
const departments = Array.from(
new Map(
person.personDepartments
.map((item) => item.department)
.filter(
(
department
): department is NonNullable<(typeof person.personDepartments)[number]['department']> => Boolean(department)
)
.map((department) => [department.id, department] as const)
).values()
)
return {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number,
department: person.mainDepartment?.name || person.department || departments[0]?.name || null,
main_department_id: person.main_department_id,
main_department: person.mainDepartment
? {
id: person.mainDepartment.id,
name: person.mainDepartment.name,
status: person.mainDepartment.status,
parent_id: person.mainDepartment.parent_id,
source_group_id: person.mainDepartment.source_group_id,
source_system: person.mainDepartment.source_system,
last_synced_at: person.mainDepartment.last_synced_at
}
: null,
departments,
department_names: departments.map((department) => department.name),
position: person.position,
phone: person.phone,
email: person.email,
status: person.status,
remarks: person.remarks,
source_userid: person.source_userid,
source_system: person.source_system,
source_main_department_id: person.source_main_department_id,
source_dept_ids: person.source_dept_ids,
source_department_name: person.source_department_name,
source_main_department_name: person.source_main_department_name,
department_sync_bucket: person.department_sync_bucket,
manager_source_userid: person.manager_source_userid,
source_employee_status: person.source_employee_status,
last_synced_at: person.last_synced_at,
is_synced: Boolean(person.source_system),
created_at: person.created_at,
updated_at: person.updated_at
}
}
const normalizedUnassignedPeople = [...unassignedPeople].sort(comparePeople).map(serializeOrganizationPerson)
const buildNode = (department: typeof departments[number]): any => {
const normalizedPeople = (peopleByDepartmentId.get(department.id) || []).map(serializeOrganizationPerson)
const childNodes = (childrenMap.get(department.id) || []).map(buildNode)
const totalPersonCount =
normalizedPeople.length + childNodes.reduce((sum: number, child: any) => sum + child.total_person_count, 0)
return {
...department,
equipment_count: department._count.equipments,
children_count: department._count.children,
person_count: department._count.personDepartments,
direct_person_count: normalizedPeople.length,
total_person_count: totalPersonCount,
people: normalizedPeople,
children: childNodes
}
}
const tree = (childrenMap.get(null) || []).map(buildNode)
return {
departments: tree,
unassigned_people: normalizedUnassignedPeople
}
}
// 获取启用的部门列表(下拉选项,含层级信息)
export const getDepartmentOptions = async (_req: Request, res: Response) => {
try {
const departments = await prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [
buildBusinessDepartmentWhere(),
{ status: 'active' }
]
},
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
@ -144,6 +421,7 @@ export const getDepartmentOptions = async (_req: Request, res: Response) => {
export const getDepartmentTree = async (_req: Request, res: Response) => {
try {
const departments = await prisma.department.findMany({
where: buildBusinessDepartmentWhere(),
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
@ -161,17 +439,101 @@ export const getDepartmentTree = async (_req: Request, res: Response) => {
}
}
export const getOrganizationTree = async (_req: Request, res: Response) => {
try {
const [departments, allPeople] = await Promise.all([
prisma.department.findMany({
where: buildBusinessDepartmentWhere(),
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
_count: {
select: { equipments: true, children: true, personDepartments: true }
}
}
}),
prisma.person.findMany({
orderBy: [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }],
include: {
mainDepartment: {
select: {
id: true,
name: true,
status: true,
parent_id: true,
source_group_id: true,
source_system: true,
last_synced_at: true
}
},
personDepartments: {
select: {
department: {
select: {
id: true,
name: true,
status: true,
parent_id: true,
source_group_id: true,
source_system: true,
last_synced_at: true
}
}
}
}
}
})
])
const businessDepartmentIdSet = new Set(departments.map((department) => department.id))
const people = allPeople.filter((person) => {
const businessDepartmentIds = resolveBusinessDepartmentIdsForPerson(person, businessDepartmentIdSet)
if (businessDepartmentIds.length > 0) {
return true
}
return !hasAnyLinkedDepartment(person)
})
const tree = buildOrganizationTree(departments, people as any)
const activePersonTotal = people.filter((person) => person.status === 'active').length
const syncedPersonTotal = people.filter((person) => Boolean(person.source_system)).length
successResponse(
res,
{
...tree,
summary: {
department_total: departments.length,
active_department_total: departments.filter((department) => department.status === 'active').length,
person_total: people.length,
active_person_total: activePersonTotal,
inactive_person_total: people.length - activePersonTotal,
synced_person_total: syncedPersonTotal,
local_person_total: people.length - syncedPersonTotal,
unassigned_person_total: tree.unassigned_people.length
}
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取组织架构失败')
}
}
// 获取部门列表(分页)
export const getDepartmentList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, keyword, status } = req.query
const where: any = {}
const where: any = {
AND: [buildBusinessDepartmentWhere()]
}
if (status) {
where.status = status
where.AND.push({ status })
}
if (keyword) {
where.name = { contains: keyword as string }
where.AND.push({ name: { contains: keyword as string } })
}
const skip = (Number(page) - 1) * Number(pageSize)
@ -224,8 +586,11 @@ export const getDepartmentList = async (req: Request, res: Response) => {
const validateParentDepartment = async (parentId: number | null | undefined, currentId?: number): Promise<string | null> => {
if (!parentId) return null
const parent = await prisma.department.findUnique({
where: { id: parentId },
const parent = await prisma.department.findFirst({
where: {
id: parentId,
...buildBusinessDepartmentWhere()
},
select: { id: true, parent_id: true, status: true }
})
@ -306,8 +671,11 @@ export const updateDepartment = async (req: Request, res: Response) => {
const { id } = req.params
const { name, status, parent_id } = req.body as { name?: string; status?: string; parent_id?: number | null }
const existing = await prisma.department.findUnique({
where: { id: Number(id) },
const existing = await prisma.department.findFirst({
where: {
id: Number(id),
...buildBusinessDepartmentWhere()
},
include: {
_count: { select: { children: true } }
}
@ -362,8 +730,11 @@ export const deleteDepartment = async (req: Request, res: Response) => {
try {
const { id } = req.params
const existing = await prisma.department.findUnique({
where: { id: Number(id) },
const existing = await prisma.department.findFirst({
where: {
id: Number(id),
...buildBusinessDepartmentWhere()
},
include: {
_count: {
select: { equipments: true, children: true, mainPersons: true, personDepartments: true }

View File

@ -9,6 +9,7 @@ import {
sumBundleChildrenValue,
syncMountedChildrenState
} from '../utils/assetValue'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
type EquipmentEntryMode = 'single' | 'parent' | 'child'
@ -61,6 +62,33 @@ const includeStorageLocation = {
}
}
const activeBorrowSelect = {
borrow_records: {
where: {
status: '已借出' as const
},
orderBy: [{ borrow_time: 'desc' as const }, { id: 'desc' as const }],
take: 1,
select: {
id: true,
person_id: true,
person_name: true,
person_nickname: true,
person_identifier: true,
borrow_time: true,
status: true,
person: {
select: {
id: true,
nickname: true,
real_name: true,
id_number: true
}
}
}
}
}
const includeEquipmentRelations = {
...includeStorageLocation,
parent: {
@ -81,9 +109,11 @@ const includeEquipmentRelations = {
equipment_type: true,
unit_price: true,
status: true,
mount_status: true
mount_status: true,
...activeBorrowSelect
}
}
},
...activeBorrowSelect
}
const toOptionalNumber = (value: unknown) => {
@ -92,6 +122,51 @@ const toOptionalNumber = (value: unknown) => {
return Number.isNaN(numericValue) ? null : numericValue
}
const mapCurrentBorrow = (borrowRecords: any[] | undefined) => {
const record = borrowRecords?.[0]
if (!record) {
return null
}
return {
id: record.id,
person_id: record.person_id,
person_name: record.person_name,
person_nickname: record.person_nickname,
person_identifier: record.person_identifier,
borrow_time: record.borrow_time,
status: record.status,
person: record.person
? {
id: record.person.id,
nickname: record.person.nickname,
real_name: record.person.real_name,
id_number: record.person.id_number
}
: null
}
}
const serializeEquipmentListItem = (equipment: any) => {
const children = Array.isArray(equipment.children)
? equipment.children.map((child: any) => {
const { borrow_records, ...restChild } = child
return {
...restChild,
current_borrow: mapCurrentBorrow(borrow_records)
}
})
: []
const { borrow_records, ...restEquipment } = equipment
return {
...restEquipment,
children,
current_borrow: mapCurrentBorrow(borrow_records)
}
}
const normalizeEntryMode = (value: unknown): EquipmentEntryMode => {
if (value === 'parent' || value === 'child') return value
return 'single'
@ -137,6 +212,30 @@ const ensureAssetTypeExists = async (client: any, equipmentType?: string, assetT
return assetType
}
const ensureBusinessDepartmentExists = async (client: any, departmentId?: number | null) => {
if (!departmentId) {
return null
}
const department = await client.department.findFirst({
where: {
id: departmentId,
status: 'active',
...buildSyncedBusinessDepartmentWhere()
},
select: {
id: true,
name: true
}
})
if (!department) {
throw new Error('所选使用部门不存在或已停用')
}
return department
}
const resolveEquipmentCode = async (
client: any,
codeGenerationMode: 'auto' | 'manual',
@ -197,6 +296,7 @@ const createEquipmentRecord = async (
const assetTypeId = toOptionalNumber(input.asset_type_id)
await ensureLocationExists(client, locationId)
await ensureBusinessDepartmentExists(client, departmentId)
const assetType = await ensureAssetTypeExists(client, input.equipment_type, assetTypeId)
const codeGenerationMode = input.code_generation_mode === 'manual' ? 'manual' : 'auto'
@ -267,10 +367,58 @@ export const getEquipmentList = async (req: Request, res: Response) => {
}
if (keyword) {
const normalizedKeyword = String(keyword).trim()
const ownSearchConditions = [
{ equipment_name: { contains: keyword as string } },
{ equipment_code: { contains: keyword as string } },
{ equipment_model: { contains: keyword as string } }
{ equipment_name: { contains: normalizedKeyword } },
{ equipment_code: { contains: normalizedKeyword } },
{ equipment_model: { contains: normalizedKeyword } },
{
department: {
is: {
name: { contains: normalizedKeyword }
}
}
},
{
storageLocation: {
is: {
name: { contains: normalizedKeyword }
}
}
},
{
borrow_records: {
some: {
status: '已借出',
OR: [
{ person_nickname: { contains: normalizedKeyword } },
{ person_name: { contains: normalizedKeyword } },
{ person_identifier: { contains: normalizedKeyword } },
{
person: {
is: {
nickname: { contains: normalizedKeyword }
}
}
},
{
person: {
is: {
real_name: { contains: normalizedKeyword }
}
}
},
{
person: {
is: {
id_number: { contains: normalizedKeyword }
}
}
}
]
}
}
}
]
where.OR = hasParentFilter
@ -335,6 +483,7 @@ export const getEquipmentList = async (req: Request, res: Response) => {
orderBy: { created_at: 'desc' },
include: {
...includeStorageLocation,
...activeBorrowSelect,
parent: {
select: {
id: true,
@ -351,7 +500,8 @@ export const getEquipmentList = async (req: Request, res: Response) => {
equipment_type: true,
unit_price: true,
status: true,
mount_status: true
mount_status: true,
...activeBorrowSelect
}
}
}
@ -362,7 +512,7 @@ export const getEquipmentList = async (req: Request, res: Response) => {
successResponse(
res,
{
list,
list: list.map(serializeEquipmentListItem),
total,
page: Number(page),
pageSize: Number(pageSize)
@ -601,6 +751,7 @@ export const updateEquipment = async (req: Request, res: Response) => {
if (department_id !== undefined) {
targetDeptId = department_id ? Number(department_id) : null
}
await ensureBusinessDepartmentExists(prisma, targetDeptId)
// 处理资产类型ID
let targetTypeId = currentEquipment.asset_type_id

View File

@ -7,6 +7,7 @@ import {
getEquipmentMountedChildrenValue,
sumSnapshotChildrenValue
} from '../utils/assetValue'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ExcelJS = require('exceljs')
@ -16,7 +17,9 @@ export const generateImportTemplate = async (req: Request, res: Response) => {
// 从数据库获取所有启用的选项数据
const [departments, assetTypes, storageLocations] = await Promise.all([
prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
},
select: { name: true },
orderBy: { name: 'asc' }
}),
@ -371,7 +374,9 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
try {
// 从数据库获取所有启用的部门
const departments = await prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
},
select: { name: true },
orderBy: { name: 'asc' }
})

View File

@ -0,0 +1,133 @@
import type { Request, Response } from 'express'
import { errorResponse, successResponse } from '../utils/response'
import {
assignUnassignedTaskItem,
closeInventoryTask,
createInventoryTask,
getInventoryTaskDetail,
getTaskUnassignedItems,
listInventoryTasks,
previewInventoryTask
} from '../services/inventoryService'
const toOptionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return null
const numericValue = Number(value)
return Number.isNaN(numericValue) ? null : numericValue
}
export const previewInventoryTaskController = async (req: Request, res: Response) => {
try {
const result = await previewInventoryTask({
departmentId: toOptionalNumber(req.body?.department_id),
assetTypeId: toOptionalNumber(req.body?.asset_type_id)
})
successResponse(res, result, '预览成功')
} catch (error: any) {
errorResponse(res, error.message || '预览盘点任务失败', 422)
}
}
export const createInventoryTaskController = async (req: Request, res: Response) => {
try {
const currentUserId = Number(res.locals.user?.id)
if (!Number.isInteger(currentUserId) || currentUserId <= 0) {
return errorResponse(res, '未获取到当前登录用户', 401)
}
const result = await createInventoryTask(
{
title: req.body?.title,
description: req.body?.description,
department_id: toOptionalNumber(req.body?.department_id),
asset_type_id: toOptionalNumber(req.body?.asset_type_id)
},
currentUserId
)
successResponse(res, result, '创建成功')
} catch (error: any) {
errorResponse(res, error.message || '创建盘点任务失败', 422)
}
}
export const getInventoryTaskListController = async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1)
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize) || 10))
const result = await listInventoryTasks(page, pageSize)
successResponse(res, result, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取盘点任务列表失败', 500)
}
}
export const getInventoryTaskDetailController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
const result = await getInventoryTaskDetail(taskId)
successResponse(res, result, '获取成功')
} catch (error: any) {
const message = error.message || '获取盘点任务详情失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const closeInventoryTaskController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
const currentUserId = Number(res.locals.user?.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
if (!Number.isInteger(currentUserId) || currentUserId <= 0) {
return errorResponse(res, '未获取到当前登录用户', 401)
}
await closeInventoryTask(taskId, currentUserId)
successResponse(res, null, '任务已关闭')
} catch (error: any) {
const message = error.message || '关闭盘点任务失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
const list = await getTaskUnassignedItems(taskId)
successResponse(res, list, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取无人归属资产失败', 500)
}
}
export const assignInventoryTaskUnassignedController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
const itemId = Number(req.params.itemId)
const personId = Number(req.body?.person_id)
if (!Number.isInteger(taskId) || taskId <= 0 || !Number.isInteger(itemId) || itemId <= 0) {
return errorResponse(res, '待处理资产不存在', 404)
}
if (!Number.isInteger(personId) || personId <= 0) {
return errorResponse(res, '目标人员不能为空', 422)
}
await assignUnassignedTaskItem(taskId, itemId, personId)
successResponse(res, null, '分配成功')
} catch (error: any) {
const message = error.message || '分配无人归属资产失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 422)
}
}

View File

@ -1,6 +1,7 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
const departmentSelect = {
id: true,
@ -89,6 +90,9 @@ const sanitizePerson = (person: any) => {
source_system: person.source_system,
source_main_department_id: person.source_main_department_id,
source_dept_ids: person.source_dept_ids,
source_department_name: person.source_department_name,
source_main_department_name: person.source_main_department_name,
department_sync_bucket: person.department_sync_bucket,
manager_source_userid: person.manager_source_userid,
source_employee_status: person.source_employee_status,
last_synced_at: person.last_synced_at,
@ -142,7 +146,8 @@ const resolveDepartmentByName = async (departmentName: string | null) => {
return prisma.department.findFirst({
where: {
name: departmentName,
status: 'active'
status: 'active',
...buildSyncedBusinessDepartmentWhere()
},
select: {
id: true,
@ -244,7 +249,7 @@ export const createPerson = async (req: Request, res: Response) => {
if (normalizedIdNumber) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '证件编号已存在', 409)
return errorResponse(res, '工号已存在', 409)
}
}
@ -309,7 +314,7 @@ export const updatePerson = async (req: Request, res: Response) => {
if (normalizedIdNumber && normalizedIdNumber !== person.id_number) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '证件编号已存在', 409)
return errorResponse(res, '工号已存在', 409)
}
}

View File

@ -0,0 +1,189 @@
import type { Request, Response } from 'express'
import { errorResponse, successResponse } from '../utils/response'
import {
confirmInventoryParticipant,
getInventoryTaskPublicInfo,
getParticipantItems,
getParticipantTaskContext,
searchTaskParticipants,
submitInventoryException,
submitInventoryScan
} from '../services/inventoryService'
const INVENTORY_SESSION_KEY = 'inventoryParticipant'
const getInventorySession = (req: Request) => ((req.session as any)?.[INVENTORY_SESSION_KEY] || null) as
| {
taskId: number
participantId: number
publicToken: string
}
| null
const setInventorySession = (
req: Request,
payload: {
taskId: number
participantId: number
publicToken: string
}
) => {
;(req.session as any)[INVENTORY_SESSION_KEY] = payload
}
const clearInventorySession = (req: Request) => {
if ((req.session as any)?.[INVENTORY_SESSION_KEY]) {
delete (req.session as any)[INVENTORY_SESSION_KEY]
}
}
const ensureInventorySession = async (req: Request) => {
const session = getInventorySession(req)
if (!session) {
throw new Error('盘点会话不存在,请重新确认身份')
}
const taskContext = await getParticipantTaskContext(session.taskId, session.participantId)
if (taskContext.task.public_token !== session.publicToken) {
clearInventorySession(req)
throw new Error('盘点会话已失效,请重新确认身份')
}
return {
session,
...taskContext
}
}
export const getPublicInventoryTaskController = async (req: Request, res: Response) => {
try {
const data = await getInventoryTaskPublicInfo(req.params.token)
successResponse(res, data, '获取成功')
} catch (error: any) {
const message = error.message || '获取盘点任务失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const searchPublicInventoryParticipantsController = async (req: Request, res: Response) => {
try {
const keyword = typeof req.query.keyword === 'string' ? req.query.keyword : ''
const list = await searchTaskParticipants(req.params.token, keyword)
successResponse(res, list, '获取成功')
} catch (error: any) {
const message = error.message || '搜索盘点人员失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const confirmPublicInventoryParticipantController = async (req: Request, res: Response) => {
try {
const participantId = Number(req.body?.participant_id)
if (!Number.isInteger(participantId) || participantId <= 0) {
return errorResponse(res, '请选择盘点人员', 422)
}
const data = await confirmInventoryParticipant(req.params.token, participantId)
setInventorySession(req, {
taskId: data.task_id,
participantId: data.participant_id,
publicToken: req.params.token
})
successResponse(res, data, '确认成功')
} catch (error: any) {
const message = error.message || '确认盘点身份失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 422)
}
}
export const getPublicInventoryMeController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
successResponse(
res,
{
task: {
id: task.id,
title: task.title,
status: task.status,
scope_department_name: task.scope_department_name,
scope_asset_type_name: task.scope_asset_type_name,
completed_count: task.completed_count,
exception_count: task.exception_count,
items_count: task.items_count,
closed_at: task.closed_at
},
participant
},
'获取成功'
)
} catch (error: any) {
const message = error.message || '获取盘点会话失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 401)
}
}
export const getPublicInventoryItemsController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
const data = await getParticipantItems(task.id, participant.id)
successResponse(res, data, '获取成功')
} catch (error: any) {
const message = error.message || '获取盘点资产失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 401)
}
}
export const submitPublicInventoryScanController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
if (task.status === 'closed') {
return errorResponse(res, '盘点任务已关闭', 403)
}
const equipmentCode = String(req.body?.equipment_code || '').trim()
if (!equipmentCode) {
return errorResponse(res, '资产编码不能为空', 422)
}
const data = await submitInventoryScan(task.id, participant.id, equipmentCode)
successResponse(res, data, '盘点成功')
} catch (error: any) {
const message = error.message || '提交盘点结果失败'
errorResponse(
res,
message,
message.includes('盘点范围') || message.includes('不能为空') ? 422 : message.includes('不存在') ? 404 : 401
)
}
}
export const submitPublicInventoryExceptionController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
if (task.status === 'closed') {
return errorResponse(res, '盘点任务已关闭', 403)
}
const itemId = Number(req.params.itemId)
if (!Number.isInteger(itemId) || itemId <= 0) {
return errorResponse(res, '盘点资产不存在', 404)
}
await submitInventoryException(
task.id,
participant.id,
itemId,
String(req.body?.exception_type || ''),
req.body?.exception_note
)
successResponse(res, null, '异常已提交')
} catch (error: any) {
const message = error.message || '提交盘点异常失败'
errorResponse(
res,
message,
message.includes('不存在') ? 404 : message.includes('无效') ? 422 : 401
)
}
}

View File

@ -25,6 +25,8 @@ import exportRoutes from './routes/export'
import searchRoutes from './routes/search'
import syncRoutes from './routes/sync'
import notificationRoutes from './routes/notifications'
import inventoryRoutes from './routes/inventory'
import publicInventoryRoutes from './routes/publicInventory'
import { keycloak, memoryStore } from './utils/keycloak'
import { protect, requireAdmin } from './middleware/auth'
import { startHrSyncScheduler } from './services/hrSyncService'
@ -81,6 +83,8 @@ app.use('/api/export', protect(), exportRoutes)
app.use('/api/search', protect(), searchRoutes)
app.use('/api/sync', protect(), requireAdmin(), syncRoutes)
app.use('/api/notifications', notificationRoutes)
app.use('/api/inventory', inventoryRoutes)
app.use('/api/public/inventory', publicInventoryRoutes)
// Health check
app.get('/api/health', (req, res) => {

View File

@ -2,6 +2,7 @@ import { Router } from 'express'
import {
getDepartmentOptions,
getDepartmentTree,
getOrganizationTree,
getDepartmentList,
createDepartment,
updateDepartment,
@ -13,6 +14,7 @@ const router = Router()
router.get('/options', protect(), getDepartmentOptions)
router.get('/tree', protect(), getDepartmentTree)
router.get('/organization-tree', protect(), requireAdmin(), getOrganizationTree)
router.get('/list', protect(), requireAdmin(), getDepartmentList)
router.post('/', protect(), requireAdmin(), createDepartment)
router.put('/:id', protect(), requireAdmin(), updateDepartment)

View File

@ -0,0 +1,25 @@
import { Router } from 'express'
import {
assignInventoryTaskUnassignedController,
closeInventoryTaskController,
createInventoryTaskController,
getInventoryTaskDetailController,
getInventoryTaskListController,
getInventoryTaskUnassignedController,
previewInventoryTaskController
} from '../controllers/inventoryController'
import { protect, requireAdmin } from '../middleware/auth'
const router = Router()
router.use(protect(), requireAdmin())
router.post('/tasks/preview', previewInventoryTaskController)
router.post('/tasks', createInventoryTaskController)
router.get('/tasks', getInventoryTaskListController)
router.get('/tasks/:id', getInventoryTaskDetailController)
router.post('/tasks/:id/close', closeInventoryTaskController)
router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController)
router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController)
export default router

View File

@ -0,0 +1,22 @@
import { Router } from 'express'
import {
confirmPublicInventoryParticipantController,
getPublicInventoryItemsController,
getPublicInventoryMeController,
getPublicInventoryTaskController,
searchPublicInventoryParticipantsController,
submitPublicInventoryExceptionController,
submitPublicInventoryScanController
} from '../controllers/publicInventoryController'
const router = Router()
router.get('/tasks/:token', getPublicInventoryTaskController)
router.get('/tasks/:token/participants', searchPublicInventoryParticipantsController)
router.post('/tasks/:token/participants/confirm', confirmPublicInventoryParticipantController)
router.get('/me', getPublicInventoryMeController)
router.get('/me/items', getPublicInventoryItemsController)
router.post('/me/scan', submitPublicInventoryScanController)
router.post('/me/items/:itemId/exception', submitPublicInventoryExceptionController)
export default router

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
export const HR_SOURCE_SYSTEM = 'ding_hr'
export const HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM = 'ding_hr_business'
export const OTHER_DEPARTMENT_NAME = '其他'
export const BUSINESS_DEPARTMENT_SOURCE_SYSTEMS = [
null,
HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM
] as const
export const isBusinessDepartmentSourceSystem = (value: string | null | undefined) =>
!value || value === HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM
export const buildBusinessDepartmentWhere = () => ({
OR: [
{ source_system: null },
{ source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM }
]
})
export const buildSyncedBusinessDepartmentWhere = () => ({
source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM
})

View File

@ -0,0 +1,149 @@
# 2026-03-19 组织架构改造交接
## 1. 本次目标
- 用户要求把“使用者信息 + 使用部门”合并成统一组织架构视图。
- 目标效果:
- 系统设置里保留 `人员维护` 页,作为批量维护入口。
- 原 `使用部门` 页改造成 `组织架构` 页。
- 部门支持层级结构。
- 员工直接显示在部门节点下面。
## 2. 已完成内容
### 2.1 后端
- 新增组织架构接口:
- `GET /api/departments/organization-tree`
- 相关文件:
- `backend/src/controllers/departmentController.ts`
- `backend/src/routes/departments.ts`
- `backend/src/utils/organization.ts`
- 当前接口能力:
- 返回部门树
- 返回每个部门节点下的员工
- 返回未归属员工
- 返回汇总统计
- 已修复的问题:
- 员工可挂多个业务部门节点,不再只落一个部门
- 非业务部门/历史污染部门的人,不再误算为“未归属”
- `/organization-tree` 已收紧为管理员接口:
- `protect() + requireAdmin()`
### 2.2 前端
- 相关文件:
- `frontend/src/views/settings/Settings.vue`
- `frontend/src/views/settings/components/DepartmentManagement.vue`
- `frontend/src/api/department.ts`
- `frontend/src/types/index.ts`
- 已完成效果:
- 系统设置页签文案改为:
- `人员维护`
- `组织架构`
- `组织架构` 页显示:
- 部门树
- 部门节点下的员工节点
- 右侧新增部门卡片
- 右侧未归属员工卡片
- 搜索/筛选已按部门上下文处理
- 筛选后部门统计口径已与显示结果保持一致
## 3. 本地验证结果
- 已执行:
- `cd frontend && npm run build`
- `cd backend && npm run build`
- 结果:
- 前端构建通过
- 后端构建通过
## 4. 线上部署情况
### 4.1 安全红线
- 服务器有两套系统:
- 固定资产:`/opt/equipment-management`
- 摄影借还:`/opt/shebeiguanli`
- 本次只操作了固定资产系统。
- 摄影系统容器 `shebeiguanli-*` 未被修改、停止、删除或重建。
### 4.2 已做过的部署动作
- 执行过 `/opt/equipment-management/deploy.sh`
- 执行过固定资产系统备份
- 后续发现“服务器源码仍是旧版”后,又额外做了:
- 关键文件精确上传到服务器
- `frontend` / `backend` 做 `--no-cache` 强制重建
- 强制 `up -d --force-recreate`
### 4.3 线上验活结果
- 健康检查通过:
- `http://localhost:3000/api/health`
- `https://www.jsgdzc.cn/api/health`
- 固定资产容器状态正常:
- `equipment-frontend`
- `equipment-backend`
- `equipment-mysql`
- 摄影系统容器仍正常:
- `shebeiguanli-frontend`
- `shebeiguanli-backend`
- `shebeiguanli-mysql`
## 5. 当前关键判断
- 服务器源码已经是新版。
- 前端容器已经做过无缓存重建。
- 新的前端静态资源里,已经确认包含:
- `组织架构`
- `人员维护`
- 并且已经确认不再包含旧的:
- `使用部门`
- `Department Directory`
- 但用户当时截图仍看到旧界面,极大概率是浏览器缓存 / 旧 HTML 入口缓存导致仍指向旧的 `Settings-*.js`。
## 6. 当前建议接手动作
下一个 agent 先不要再大改代码,先做“线上结果核实”:
1. 让用户用无痕窗口打开:
- `https://www.jsgdzc.cn/settings?tab=departments&v=202603191820`
2. 或指导用户强刷缓存:
- `Ctrl + F5`
- 手机端则清理缓存后重开
3. 如果依然是旧界面:
- 直接检查线上 `index.html` 是否仍引用旧版 `Settings-*.js`
- 再检查 nginx 对 `index.html` 的缓存策略
## 7. 可能的下一步修复点
如果确认是首页 HTML 缓存问题,优先处理:
- `frontend/nginx.conf`
- 重点不是静态资源 `.js/.css`,而是 `index.html` 不能被长缓存
- 当前配置里静态资源有 `immutable`,但 `index.html` 没有单独禁缓存规则,可能需要补:
- `Cache-Control: no-store, no-cache, must-revalidate`
## 8. 本次涉及的关键文件
- 后端:
- `backend/src/controllers/departmentController.ts`
- `backend/src/routes/departments.ts`
- `backend/src/utils/organization.ts`
- 前端:
- `frontend/src/views/settings/Settings.vue`
- `frontend/src/views/settings/components/DepartmentManagement.vue`
- `frontend/src/api/department.ts`
- `frontend/src/types/index.ts`
- 部署相关:
- `deploy.sh`
- `frontend/nginx.conf`
## 9. 注意事项
- 仓库当前是 dirty worktree,很多文件不是本次单独改动,接手时不要误回滚。
- 不要进入 `/opt/shebeiguanli`。
- 不要操作 `shebeiguanli-*` 容器。
- 不要碰摄影系统数据库。
- 如需再次部署,只允许针对固定资产系统路径和容器。

View File

@ -0,0 +1,332 @@
# 2026-03-19 项目总交接
## 1. 项目定位
- 项目:固定资产管理系统
- 本地目录:`D:\AI Code\GuDingZiChan`
- 线上目录:`/opt/equipment-management`
- 线上域名:
- `https://www.jsgdzc.cn`
- `https://jsgdzc.cn`
## 2. 服务器红线
服务器 `1.13.22.133` 上有两套系统:
- 固定资产系统:`/opt/equipment-management`
- 摄影设备借还系统:`/opt/shebeiguanli`
必须遵守:
- 任何远程操作只能针对固定资产系统
- 不得进入 `/opt/shebeiguanli`
- 不得停止、删除、重建、修改 `shebeiguanli-*` 容器
- 不得碰摄影系统数据库、数据卷、配置
## 3. 当前已推进过的主线
### 3.1 资产基础流程
项目已经具备并持续在用的主流程:
- 资产录入
- 资产入库
- 资产借出
- 资产归还
- 资产维修
- 资产报废
- 二维码打印
- 移动端扫码借还
### 3.2 父资产 / 子资产
已经按“父子资产”模式推进过一轮:
- 父资产录入时支持一起录入子资产
- 子资产逻辑上独立,不是一次性打包死绑定
- 子资产可以后续拆卸、转移、重新挂到其他父资产
- 新增父资产时,已支持自动带出一组常见电脑配件子资产的录入模板方向
用户明确过的业务理解:
- 子资产虽然创建时跟着父资产一起录,但后续必须可独立流转
- 不能把“创建时一起录入”理解成“永远打包不可分”
### 3.3 资产价值体系
价值体系相关已经做过一轮梳理和修正方向确认:
- 父资产与子资产价值不能简单永远固化
- 如果子资产被拆走,父资产价值应该随之变化
- 数量型资产的总价与单价逻辑用户已明确:
- 例如内存条单价 `500`
- 数量 `2`
- 当前录入理解应是“这一批总价值 1000”,不是“两条都显示 500 的整套价”
后续如果继续改价值口径,需要重点核对:
- 资产录入单价/总价
- 借出价值/可用价值/总价值统计
- 父子资产拆装后的价值变动
### 3.4 标签打印 / 打印机适配
这条线做过很多轮,已经沉淀出一些关键经验:
- 打印机:`H500E`
- 打印清晰度:`300dpi`
- 客服确认过介质宽度和高度范围
- 实际测试过标签尺寸问题:
- 曾经混淆过 `6*10`、`10*6`
- 最终确认用户当时量到的测试纸接近 `101.6 x 63.5mm`
- 用户后续明确方向是:
- 当前测试纸先将就
- 最终仍要回到 `60*40` 标签布局
已确认过的经验:
- 浏览器打印弹窗并非一定是系统代码问题,有一部分和驱动 / 浏览器 / 打印方式有关
- 当时存在“预览图正常,但实际打印被切成多张标签”的问题,根因与尺寸/驱动设置/页面布局共同有关
- 后面已经做过若干轮标签样式调整,用户确认过一版“暂时先这样”
### 3.5 移动端扫码与 HTTPS
这条线已经做过并上线过一轮:
- 移动端页面已做过适配
- 手机扫码调用摄像头功能已接入
- 域名 HTTPS 已配好
- 安卓设备调用摄像头的问题,用户后来确认和浏览器安全策略有关
结论:
- HTTPS 是手机扫码的前提
- 不同浏览器对摄像头权限的表现不同
- 若再次出现“当前设备没有检测到可用摄像头 / 打开摄像头失败”,先检查:
- 是否 HTTPS
- 是否浏览器权限
- 是否设备浏览器兼容问题
### 3.6 前端视觉重构
前端视觉并不是由我单独完成的,过程中有另一个 agent 参与重构,当前要点是:
- 视觉方向固定为:
- 极简
- 统一
- 深色主题
- 规范文件:
- `D:\AI Code\GuDingZiChan\前端视觉规范.md`
- 另有前端台账和视觉总结类文档,已经被多次引用
当前应遵守的原则:
- 不要再回到旧的 Apple 风格类名
- 不要渐变卡片背景
- 输入框和按钮布局要统一
- 左侧导航要固定,不能跟着滚动丢用户信息
- 移动端页面要能完整滚动,底部操作不能遮挡
### 3.7 Logo 与品牌资源
Logo 资源这条线也改过:
- 用户后来明确指定过 logo 素材路径:
- `D:\AI Code\GuDingZiChan\frontend\public\branding\未标题-1.jpg`
- 用户要求:
- 页面内 logo
- 浏览器标签 favicon / logo
- 都要统一
### 3.8 组织同步 / 人员部门数据
这条线是最近最复杂、最关键的主线之一。
目前已确认:
- SQL Server 源库是人事主数据来源
- 后续项目内人员/部门信息都要从这套库同步
- 目标是每天同步 1~2 次
- 后续要支持站内消息提醒行政处理差异
当前最重要的结论:
- 不能再按旧 `hr_employee + hr_groups` 作为唯一主口径
- 当前正确方向是:
- `hr_employee_register` 作为人员同步主口径
- `sys00-dept` 作为业务部门主字段
- `sys00-mainDepts` 作为正式员工空部门兜底
- `hr_groups` 只保留为历史追溯参考
两份关键文档已经写好:
- [资产盘点与组织同步联合方案.md](/D:/AI%20Code/GuDingZiChan/docs/%E8%B5%84%E4%BA%A7%E7%9B%98%E7%82%B9%E4%B8%8E%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E8%81%94%E5%90%88%E6%96%B9%E6%A1%88.md)
- [组织同步源表结构说明.md](/D:/AI%20Code/GuDingZiChan/docs/%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E6%BA%90%E8%A1%A8%E7%BB%93%E6%9E%84%E8%AF%B4%E6%98%8E.md)
### 3.9 资产盘点功能
这条线已经进入“方案明确 + 代码已开始落地”的状态。
当前仓库里已经存在:
- 后端路由:
- `backend/src/routes/inventory.ts`
- `backend/src/routes/publicInventory.ts`
- 后端控制器:
- `backend/src/controllers/inventoryController.ts`
- `backend/src/controllers/publicInventoryController.ts`
- 服务:
- `backend/src/services/inventoryService.ts`
- 前端页面:
- `frontend/src/views/inventory/InventoryList.vue`
- `frontend/src/views/inventory/InventoryCreate.vue`
- `frontend/src/views/inventory/InventoryDetail.vue`
- `frontend/src/views/inventory/PublicInventoryTask.vue`
但这条线不能只看“文件在不在”,必须继续按联合方案文档核:
- 组织同步准确性是否足够支撑盘点
- 免登录员工盘点流程是否完全符合文档
- 行政端任务、异常、无人归属处理是否全部打通
### 3.10 通知中心 / 站内消息
用户要求过:
- 钉钉通知先不做
- 先做站内消息提醒行政
仓库当前已存在:
- 后端通知相关路由:`backend/src/routes/notifications.ts`
- 前端页面:`frontend/src/views/notifications/NotificationCenter.vue`
后续如果继续推进这条线,要重点核:
- 组织同步差异是否真正落成站内通知
- 通知是否支持已读/未读、跳转关联任务
## 4. 这次最近在做的线
最近这轮的直接任务是:
- 把“使用者信息 + 使用部门”合并成统一的“组织架构”视图
当前这条线的本地代码状态:
- `Settings.vue`
- 页签文案已经改成:
- `人员维护`
- `组织架构`
- `DepartmentManagement.vue`
- 已改造成“组织架构”页
- 部门树下直接显示员工节点
- 后端已新增:
- `GET /api/departments/organization-tree`
- 并且修过这几个关键问题:
- 员工可挂多个业务部门节点
- 未归属员工不再把旧污染关系误算进去
- 前端筛选后统计口径一致
- 组织架构接口已收紧为管理员权限
对应交接文档:
- [2026-03-19_组织架构改造交接.md](/D:/AI%20Code/GuDingZiChan/docs/2026-03-19_%E7%BB%84%E7%BB%87%E6%9E%B6%E6%9E%84%E6%94%B9%E9%80%A0%E4%BA%A4%E6%8E%A5.md)
## 5. 线上状态
### 5.1 已完成的线上动作
- 固定资产系统已部署到服务器
- 已确认:
- `equipment-frontend`
- `equipment-backend`
- `equipment-mysql`
都正常运行
- 摄影系统容器保持正常运行,未受影响
- 域名健康检查正常:
- `https://www.jsgdzc.cn`
- `https://www.jsgdzc.cn/api/health`
### 5.2 当前线上疑点
最新发现:
- 服务器源码已经是新版
- 前端容器也做过 `--no-cache` 强制重建
- 新静态资源里已确认包含:
- `组织架构`
- `人员维护`
- 用户当时看到的旧界面,极大概率是浏览器缓存 / 首页 HTML 缓存导致
当前最应该继续核的点:
- `index.html` 是否还被缓存
- 浏览器是否仍引用旧的 `Settings-*.js`
- 是否需要在 `frontend/nginx.conf` 里对 `index.html` 单独加禁缓存
## 6. 当前未完成 / 待下一位 agent 接手的重点
### 6.1 优先级最高
1. 继续核实线上“组织架构”页为何用户仍能看到旧页面
2. 如果确认是缓存问题,优先处理:
- `frontend/nginx.conf`
- `index.html` 缓存策略
3. 让用户无痕打开:
- `https://www.jsgdzc.cn/settings?tab=departments&v=202603191820`
4. 再次确认实际页面是不是新版
### 6.2 第二优先级
1. 按组织同步文档继续把人员/部门口径彻底收敛
2. 清理历史污染数据
3. 继续让用户抽样核对“系统设置 -> 员工 / 部门”是否符合真实钉钉组织
### 6.3 第三优先级
1. 继续盘点功能联调
2. 核对移动端员工免登录扫码盘点完整流程
3. 核对站内消息是否真正接到组织同步差异
## 7. 当前重要文件
### 7.1 规则与文档
- [AGENTS.md](/D:/AI%20Code/GuDingZiChan/AGENTS.md)
- [前端视觉规范.md](/D:/AI%20Code/GuDingZiChan/%E5%89%8D%E7%AB%AF%E8%A7%86%E8%A7%89%E8%A7%84%E8%8C%83.md)
- [资产盘点与组织同步联合方案.md](/D:/AI%20Code/GuDingZiChan/docs/%E8%B5%84%E4%BA%A7%E7%9B%98%E7%82%B9%E4%B8%8E%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E8%81%94%E5%90%88%E6%96%B9%E6%A1%88.md)
- [组织同步源表结构说明.md](/D:/AI%20Code/GuDingZiChan/docs/%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E6%BA%90%E8%A1%A8%E7%BB%93%E6%9E%84%E8%AF%B4%E6%98%8E.md)
- [2026-03-19_组织架构改造交接.md](/D:/AI%20Code/GuDingZiChan/docs/2026-03-19_%E7%BB%84%E7%BB%87%E6%9E%B6%E6%9E%84%E6%94%B9%E9%80%A0%E4%BA%A4%E6%8E%A5.md)
### 7.2 最近关键代码
- 后端:
- `backend/src/controllers/departmentController.ts`
- `backend/src/routes/departments.ts`
- `backend/src/services/hrSyncService.ts`
- `backend/src/utils/organization.ts`
- `backend/src/routes/inventory.ts`
- `backend/src/routes/publicInventory.ts`
- 前端:
- `frontend/src/views/settings/Settings.vue`
- `frontend/src/views/settings/components/DepartmentManagement.vue`
- `frontend/src/views/settings/components/HrSyncManagement.vue`
- `frontend/src/views/settings/components/PersonManagement.vue`
- `frontend/src/views/inventory/InventoryList.vue`
- `frontend/src/views/inventory/InventoryCreate.vue`
- `frontend/src/views/inventory/InventoryDetail.vue`
- `frontend/src/views/inventory/PublicInventoryTask.vue`
- `frontend/nginx.conf`
## 8. 当前工作树说明
当前仓库不是干净工作树,已有很多历史改动和未提交文件:
- 不要默认把所有未提交内容都当成“本次脏改动”
- 很多内容是之前主线任务的一部分
- 接手时不能随手回滚
## 9. 给下一位 agent 的一句话提醒
先别急着继续写新功能,先把“线上组织架构页为什么用户还看到旧页面”这件事彻底核清,优先排缓存和 `index.html` 引用问题;同时始终记住,任何部署和远程操作都只能碰 `/opt/equipment-management`,绝不能影响摄影借还系统。

View File

@ -0,0 +1,718 @@
# 2026-03-23 新对话完整交接
## 1. 这份文档的用途
这份文档是给“新开对话后直接接手项目的 agent”准备的。
目标不是简单列文件,而是把下面三件事一次性交代清楚:
- 这个项目现在真正要达成的目标是什么
- 项目现在已经到了什么状态,哪些是已验证的,哪些只是本地刚改还没验证
- 当前正在做什么,下一位 agent 接手后应该先干什么,不能干什么
---
## 2. 项目的真正目标
### 2.1 总目标
当前项目的真正目标不是继续堆很多新功能,而是尽快把这套固定资产系统做成一套能够替代行政当前 Excel 台账、并且稳定可交付的内部系统。
简单说,优先级是:
1. 先能替代表格
2. 先把真实资产数据迁进系统
3. 先让行政能持续维护
4. 再去继续做更大的功能扩展
### 2.2 当前主线目标
当前主线已经明确收敛到下面这件事:
- 把 `8.xlsx` 里的固定资产数据迁移进固定资产系统
- 迁移时不乱猜、不硬造人、不为了全量导入而污染系统
- 能自动确认的先导入
- 不确定的留成明确清单,交给行政补录
- 最终让系统取代 Excel,后续在系统里继续维护
### 2.3 当前不是主线的内容
下面这些内容虽然项目里都有,但当前不是第一优先级:
- 前端视觉继续大改
- 资产盘点功能继续深挖
- 组织同步继续追到和钉钉人数完全一致
- 钉钉通知
- 更复杂的统计分析
这些都没有消失,只是当前优先级低于“Excel 迁移落地”。
---
## 3. 当前项目的大背景
### 3.1 系统定位
- 项目:固定资产管理系统
- 本地目录:`D:\AI Code\GuDingZiChan`
- 线上固定资产系统目录:`/opt/equipment-management`
### 3.2 服务器红线
固定资产系统和另一套摄影设备借还系统在同一台服务器上。
必须始终遵守:
- 任何远程操作只能针对固定资产系统
- 绝不能进入摄影系统目录
- 绝不能停止、删除、重建、修改摄影系统容器
- 绝不能碰摄影系统数据库、数据卷、配置
这条是最高优先级红线。
### 3.3 用户沟通偏好
用户是非程序员,所以后续接手时要注意:
- 默认中文
- 先讲结论,再讲细节
- 不要频繁停下来问
- 能自动推进就自动推进
- 不要赌不确定的数据
- 涉及线上真实数据、不可逆动作、密钥账号、证书时才停下来确认
---
## 4. 当前项目整体状态
### 4.1 业务流程状态
项目已经具备并实际围绕这些流程在推进:
- 资产录入
- 资产入库
- 资产借出
- 资产归还
- 资产维修
- 资产报废
- 二维码打印
- 移动端扫码借还
### 4.2 父子资产状态
父资产 / 子资产这条线已经做过一轮,业务理解已经明确:
- 创建父资产时可以带一批子资产一起录
- 子资产逻辑上必须独立
- 子资产后续可以拆卸、转移、重新挂到别的父资产
- 不能把“创建时一起录”理解成“永远打包死绑定”
### 4.3 前端视觉状态
前端视觉这条线已经交给另一个 agent 重构。
当前结论:
- 视觉方向固定为:极简、统一、深色主题
- 前端不是当前主线
- 现在不要为了迁移任务去打断前端重构
- 除非迁移任务必须改页面,否则尽量少碰前端视觉
### 4.4 资产盘点状态
资产盘点和组织同步联合方案已经写成文档,并且仓库里已有一部分代码文件,但这条线当前不在第一优先级。
关键文档:
- `docs/资产盘点与组织同步联合方案.md`
当前判断:
- 盘点功能后面还要继续
- 但在真实资产迁移没收口前,不是当前最优先要继续啃的线
---
## 5. 当前“已验证”的真实状态
这一节只写已经验证过的状态,不把还没验证的本地修改混进来。
### 5.1 最新已验证 live 资产状态
截至最新一轮已验证结果,线上固定资产系统是:
- live 总资产数:`1640`
- 其中有借用记录的资产:`1247`
- 当前无标准地址的资产:`59`
无标准地址的状态分布:
- 可用:`16`
- 已借出:`43`
### 5.2 已完成的 live 导入情况
当前已经确认发生过的真实导入动作:
- 先前已导入 `1247` 条“有明确使用人”的资产
- 后续又增量导入 `393` 条“可先导入但不带使用人”的资产
- 当前 live 总数 `1640` 就是这两部分叠加后的结果
### 5.3 已完成的地址回填情况
地址回填已经做过两轮,已验证结果是:
- 之前无标准地址:`96`
- 回填后降到:`59`
已经确认并使用过的高置信度地址映射包括:
- `常州 -> 常州-天安`
- `佛山3D / 佛山平面 / 佛山建模 / 广东佛山 -> 佛山`
- `二楼分销 -> 常州-天安`
- `南京机房远程数据机 -> 南京-苏宁园区`
- `10号楼1楼机房监控 / 10号楼1楼监控室 / 10号楼1楼配电间监控 / 10号楼3楼机房 -> 南京-苏宁园区`
### 5.4 最新已验证 dry-run 结果
最新已验证 dry-run 输出目录是:
- `temp_migration_output/migration_dry_run_2026-03-23_03-01-06`
这一版 dry-run 的权威统计是:
- `importReady = 1255`
- `importWithoutOwner = 401`
- `pendingReview = 23`
- `discardNoImport = 34`
注意:
- 这里的 `1255 / 401 / 23 / 34` 是 dry-run 的分桶结果
- 不等于都已经 live 导入
- live 实际已导入的是:
- `1247` 条 with-owner
- `393` 条 without-owner
### 5.5 最新已验证行政补录清单
最新已验证行政补录工作簿是:
- `行政手动补录清单-2026-03-23_03-07-12.xlsx`
这份工作簿里的统计是:
- `待确认部门`:`23`
- `缺少编码`:`6`
- `重复编码`:
- 重复编码数:`5`
- 重复记录数:`10`
- `待补使用人`:`196`
- `花名核对`:`105`
- `待补地址`:`59`
---
## 6. 组织同步和人员部门数据的真实口径
### 6.1 当前已经确认的同步主口径
后续任何人如果再碰“人员/部门同步”,必须基于这个结论:
- `dbo.hr_employee_register`:人员同步主表
- `dbo.hr_employee`:补充字段表
- `dbo.hr_groups`:历史组织树参考表,不再作为当前主部门主依据
### 6.2 当前代码里的核心实现位置
关键文件:
- `backend/src/services/hrSyncService.ts`
- `backend/src/utils/organization.ts`
### 6.3 当前同步规则的关键点
当前系统里关于同步的核心逻辑已经收敛为:
- 业务部门优先取 `sys00-dept`
- 如果 `sys00-dept` 为空,再兜底 `sys00-mainDepts`
- 正式员工默认按状态值 `3`
- 默认只把状态值 `5` 视为 inactive
- `hr_groups` 不再决定当前业务部门
### 6.4 当前已存在的特殊排除
当前同步逻辑里已经明确做了特殊排除:
- `业务支持 + 无工号`
- `龙晨品牌中心 + register_only`
- `品牌中心 + register_only`
这条非常关键,因为它直接影响为什么有些 Excel 里的历史花名,不能为了迁移资产而强行补成当前 active 员工。
### 6.5 当前系统里的“当前使用人”模型
这个点必须记住,因为它影响整个迁移策略:
- 系统里的“当前使用人”不是挂在 `equipment.owner` 上
- 而是挂在 `BorrowRecord -> active Person` 上
所以:
- 如果为了迁移方便手工补一批假 active person
- 会直接污染当前使用人模型
- 这是明确不能做的
---
## 7. Excel 迁移这条线已经做成什么样了
### 7.1 Excel 来源
当前迁移基于:
- 根目录下的 `8.xlsx`
- 工作表:`📜固定资产明细`
### 7.2 迁移基本策略
用户已经接受并确认的迁移原则是:
- 能确定的先导入
- owner 不确定但资产本身确定的,可以先导入但不建当前使用人
- 真正不确定的继续留清单
- 不为了全量导入而乱猜
- 不为了 owner 去手工造 active person
### 7.3 dry-run 主脚本
当前 dry-run 主脚本是:
- `backend/scripts/prepareEquipmentMigrationDryRun.js`
这个脚本的职责是:
- 读取 `8.xlsx`
- 通过远程只读获取 live 引用快照
- 读取 live 的:
- active 资产类型
- active 业务部门
- active 标准地址
- active 同步人员
- 把 Excel 资产行分桶为:
- `import-ready`
- `import-without-owner`
- `pending-review`
- `discard-no-import`
### 7.4 live 导入脚本
当前 live 导入脚本是:
- `backend/scripts/importConfirmedEquipmentToLive.js`
这支脚本已经支持:
- `--keep-existing`
- `--include-ownerless`
- 排除空编码
- 排除重复编码
- 增量导入,不一定每次清库
### 7.5 地址回填脚本
当前地址回填脚本是:
- `backend/scripts/backfillImportedEquipmentLocations.js`
这支脚本已经支持:
- 不只处理 `import-ready`
- 也处理 `import-without-owner`
### 7.6 行政清单导出脚本
当前行政补录清单脚本是:
- `backend/scripts/exportAdminManualReviewWorkbook.js`
它当前已能导出:
- 概览
- 待确认部门
- 缺少编码
- 重复编码
- 待补使用人
- 花名核对
- 待补地址
如果输出文件被 Excel 占用,会自动换一个时间戳文件名。
---
## 8. 当前剩余问题到底是什么
### 8.1 还没导入的真正 pending 资产
当前真正还没有解决的 pending-review 只有 `23` 条。
按原始部门分布是:
- `品牌项目中心`:`11`
- `品牌运营中心`:`4`
- `卓禾事业部`:`3`
- `品牌项目中心(常州)`:`2`
- `品牌营销策划中心`:`1`
- `朴美项目部`:`1`
- `运营中心C`:`1`
这 `1` 条 `运营中心C` 的问题不是部门,而是原 Excel 状态为空,也就是:
- `unknown_source_status`
### 8.2 已导入但还没挂使用人的资产
当前这部分有 `196` 条。
原因构成是:
- `193` 条:`owner_not_in_active_synced_roster`
- `3` 条:`multi_owner_cell`
### 8.3 这 196 条为什么不能继续自动吃
这一步之前已经专门查过:
- 对这些历史花名,已经同时查过:
- `hr_employee_register.sys00-flowerName`
- `hr_employee_register.sys00-name`
- `hr_employee_register.sys02-realName`
- `hr_employee.name`
- 四个字段一起查,全部没有命中
结论已经明确:
- 这不是简单的“同步漏人”
- 更像是 Excel 用的是旧花名、旧称呼、历史口径
- 所以不能继续自动硬挂
- 必须行政确认后再补
### 8.4 待补地址为什么也没有继续自动猜
当前剩余 `59` 条待补地址里,主要是:
- 空白:`32`
- `电子设备`:`5`
- `河北仓`:`3`
- `天安/杭州`:`2`
- `山东仓`:`2`
- `抓数用`:`2`
- 其余各 `1`
不继续自动猜的原因已经明确:
- 空白无法推断
- `电子设备`、`抓数用` 不是地点
- `河北仓`、`山东仓` 等更像真实外部仓点,可能应该新增标准地址
- `天安/杭州` 是双地点写法,当前没有足够证据判断落哪边
### 8.5 缺少编码和重复编码
这部分当前没有自动硬导:
- 缺少编码:`6`
- 重复编码:
- 重复编码组:`5`
- 重复记录:`10`
这部分继续保持人工处理,不自动乱造编码。
---
## 9. 用户已经明确接受的业务原则
这部分非常关键,后续不能偏离。
### 9.1 关于 owner
用户已经接受:
- 有些资产可以先导入,不带 owner
- 不要为了导入方便去补假人员
- 当前使用人必须遵守系统现有的 active person + borrow record 模型
### 9.2 关于部门
用户要的是“现在项目里真正能用、并且和系统业务口径一致的部门”,不是把各种历史叶子组都塞进去。
所以:
- 业务部门要尽量干净
- 历史污染部门不要再灌回前台
- 如果历史 Excel 部门无法高置信度映射,就宁可先留清单
### 9.3 关于迁移节奏
用户接受的节奏是:
- 先把确定数据导入
- 再把不确定数据整理清单
- 行政后补
- 再回挂
用户不接受的是:
- 为了追求“一次性全量导完”而硬猜
---
## 10. 当前“正在进行”的任务
这一节是最重要的交接重点。
### 10.1 当前主任务
当前正在做的事情不是再导一轮 live,而是补齐“行政补录结果 -> 再次 dry-run / 再导入”的闭环。
原因是:
- 纯自动可吃的内容已经基本吃到头了
- 剩下的主要靠行政确认
- 如果没有一个“行政填完就能继续喂回系统”的闭环,后面又会变回人工重复劳动
### 10.2 我刚刚已经动手但还没验证的本地改动
这一轮我已经开始改了两处脚本,但**用户在中途打断要求先写交接文档,所以这两处改动还没有重新跑 dry-run 验证,也没有部署,也没有导出新工作簿验证**。
本地已改但未验证的文件:
- `backend/scripts/prepareEquipmentMigrationDryRun.js`
- `backend/scripts/exportAdminManualReviewWorkbook.js`
本地已改的方向是:
#### A. `prepareEquipmentMigrationDryRun.js`
已经开始补这些能力:
- 支持读取手工 overrides 文件
- 支持对单条资产做字段覆盖
- 支持对历史 owner / 历史部门 / 历史地址做人工映射覆盖
- 已额外补了一条高置信度部门映射:
- `品牌营销策划中心 -> 品牌策划中心`
但当前状态只是“文件已改”,**没有重新执行脚本验证结果**。
#### B. `exportAdminManualReviewWorkbook.js`
已经开始把行政工作簿改成可填写格式,方向是:
- 给 `待确认部门` 增加:
- `行政填写_目标部门`
- `行政填写_目标员工`
- `行政填写_标准地址`
- `行政填写_资产状态`
- `行政填写_备注`
- 给 `待补使用人` 增加:
- `行政填写_目标员工`
- `行政填写_备注`
- 给 `花名核对` 增加:
- `行政填写_目标员工`
- `行政填写_备注`
- 给 `待补地址` 增加:
- `行政填写_标准地址`
- `行政填写_备注`
- 增加 `填写说明` 工作表
但当前同样只是“本地已改”,**还没有重新导出工作簿验证结构**。
### 10.3 当前原计划但还没开始写的脚本
我本来下一步要继续补一支新脚本,方向已经明确,但还没开始落文件:
- 计划新增一个“行政工作簿 -> overrides JSON”的生成脚本
预期用途是:
- 行政把工作簿填完
- 脚本把填写结果转成 `migration_manual_overrides.json`
- dry-run 脚本读取这个 overrides
- 重新分桶
- 再用现有导入脚本继续导入 / 补挂 / 回填
这支脚本**目前还没开始创建文件**。
---
## 11. 下一位 agent 接手后应该怎么做
### 11.1 第一优先级
先不要继续改业务逻辑,先把“刚刚本地已改但未验证”的两处脚本跑通。
也就是先做:
1. 检查 `backend/scripts/prepareEquipmentMigrationDryRun.js` 是否有语法错误
2. 用当前 Excel 和当前 live 快照重新跑一轮 dry-run
3. 看新 dry-run 是否正常输出
4. 看 `品牌营销策划中心 -> 品牌策划中心` 这条高置信度映射是否让 pending 再减少
5. 重新跑 `exportAdminManualReviewWorkbook.js`
6. 看新工作簿列是否正确、是否真的可供行政填写
### 11.2 第二优先级
如果上面两步都正常,再继续补“工作簿 -> overrides JSON”的脚本。
这件事比继续硬猜部门更重要,因为:
- 剩下的大头已经不是代码自己能完全猜出来的
- 没有闭环,行政补完还得人工二次处理
### 11.3 第三优先级
如果 overrides 流程做完,再继续:
- 用行政填写后的结果生成 overrides
- 重新跑 dry-run
- 看还能继续自动吃掉多少 pending / ownerless / null-location
- 再决定是否继续增量 live 导入
### 11.4 当前不建议优先做的事
接手后暂时不要优先做这些:
- 再去大改前端视觉
- 再去追 773 人和当前 roster 的完全一致
- 再去做资产盘点 UI
- 再去追打印功能
- 再去清理别的历史测试线
原因很简单:
- 当前最接近交付的主线就是 Excel 迁移
- 其它线会分散精力
---
## 12. 接手时必须知道的关键文件
### 12.1 文档
- `docs/2026-03-19_项目总交接.md`
- `docs/2026-03-19_组织架构改造交接.md`
- `docs/2026-03-23_资产迁移推进记录.md`
- `docs/资产盘点与组织同步联合方案.md`
- `docs/组织同步源表结构说明.md`
### 12.2 Excel 迁移相关脚本
- `backend/scripts/prepareEquipmentMigrationDryRun.js`
- `backend/scripts/importConfirmedEquipmentToLive.js`
- `backend/scripts/backfillImportedEquipmentLocations.js`
- `backend/scripts/exportAdminManualReviewWorkbook.js`
- `backend/scripts/locationNormalization.js`
### 12.3 关键输出目录和文件
- 最新已验证 dry-run:
- `temp_migration_output/migration_dry_run_2026-03-23_03-01-06`
- 最新已验证导入备份:
- `temp_migration_output/live_import_backup_2026-03-23_02-47-21`
- 最新已验证地址回填:
- `temp_migration_output/location_backfill_2026-03-23_03-01-32`
- 最新已验证行政清单:
- `行政手动补录清单-2026-03-23_03-07-12.xlsx`
### 12.4 组织同步相关关键文件
- `backend/src/services/hrSyncService.ts`
- `backend/src/utils/organization.ts`
---
## 13. 当前已确认的一些具体判断
### 13.1 `品牌营销策划中心`
这一条已经拿到较强证据:
- `hr_groups` 里存在 `品牌营销策划中心`
- 当前系统业务部门里存在 `品牌策划中心`
- 这两者高度相关
所以这条被视为高置信度映射候选:
- `品牌营销策划中心 -> 品牌策划中心`
但要注意:
- 这条映射只是本地已经加到脚本,**还没重跑验证**
### 13.2 `品牌项目中心 / 品牌运营中心 / 卓禾事业部 / 朴美项目部`
这几条当前仍然没有足够证据做自动硬映射。
现阶段判断:
- 不适合继续拍脑袋映射
- 更适合走“行政填写 -> overrides -> 重跑”的路线
### 13.3 `运营中心C` 那 1 条
这 1 条不是部门不对,而是 Excel 原始状态为空。
所以后续应该走:
- 行政补状态
- 再决定导入
而不是去改部门。
---
## 14. 当前仓库状态提醒
### 14.1 仓库是 dirty worktree
当前仓库里有很多已修改文件,不全是这次迁移单独改的。
所以接手时要注意:
- 不要误回滚用户和其他 agent 的改动
- 尤其不要因为只看当前任务,就把别的脏文件一把清掉
### 14.2 当前这次交接里最重要的本地未验证改动
当前最需要盯住的是这两个文件:
- `backend/scripts/prepareEquipmentMigrationDryRun.js`
- `backend/scripts/exportAdminManualReviewWorkbook.js`
这两处已经改了,但还没重新执行和验证。
---
## 15. 密钥与敏感信息处理
接手后要注意:
- 不要把服务器密码、源库密码、生产连接串再写进仓库或文档
- 如果需要远程操作,沿用当前会话已有的方式和凭据
- 但不要把这些敏感信息再次落盘
---
## 16. 一句话交接结论
当前项目的主线不是继续做新功能,而是把 `8.xlsx` 的资产迁移彻底收口。
已验证状态下:
- live 已有 `1640` 条资产
- 还剩 `23` 条真正 pending 未导
- 还剩 `196` 条已导入但待补使用人
- 还剩 `59` 条待补标准地址
当前正在做的不是继续硬猜,而是补“行政填写结果 -> overrides -> 重新 dry-run / 再导入”的闭环。
本地已经改了两处脚本,但还没验证;下一位 agent 接手后,第一件事就是把这两处脚本跑通、验清楚,再继续后面的闭环脚本。

View File

@ -0,0 +1,104 @@
# 2026-03-23 资产迁移推进记录
## 当前 live 结果
- 固定资产 live 总数:1640
- 其中有借用记录的资产:1247
- 当前无标准地址的资产:59
- 当前无标准地址状态分布:
- 可用:16
- 已借出:43
## 今天已经完成的动作
- 扩展了 `backend/scripts/importConfirmedEquipmentToLive.js`
- 支持 `--keep-existing`
- 支持 `--include-ownerless`
- 支持把 `import-without-owner` 资产增量补导到 live
- 自动排除空编码和重复编码
- 已增量导入 `393` 条“不带使用人但可先导入”的资产
- 已新增并使用 `backend/scripts/exportAdminManualReviewWorkbook.js`
- 自动导出行政补录工作簿
- 如果目标文件被 Excel 占用,自动生成时间戳版本
- 已补强地址标准化规则:
- `常州 -> 常州-天安`
- `佛山3D / 佛山平面 / 佛山建模 / 广东佛山 -> 佛山`
- `二楼分销 -> 常州-天安`
- `南京机房远程数据机 -> 南京-苏宁园区`
- `10号楼1楼机房监控 / 10号楼1楼监控室 / 10号楼1楼配电间监控 / 10号楼3楼机房 -> 南京-苏宁园区`
- 已扩展 `backend/scripts/backfillImportedEquipmentLocations.js`
- 不再只处理 `import-ready`
- 也能处理 `import-without-owner`
- 今天地址回填结果:
- 第一轮回填:32 条
- 第二轮回填:5 条
- 无标准地址从 96 压到 59
## 关键输出文件
- 最新 dry-run:
- `D:\AI Code\GuDingZiChan\temp_migration_output\migration_dry_run_2026-03-23_03-01-06`
- 最新地址回填结果:
- `D:\AI Code\GuDingZiChan\temp_migration_output\location_backfill_2026-03-23_03-01-32`
- 最新导入备份:
- `D:\AI Code\GuDingZiChan\temp_migration_output\live_import_backup_2026-03-23_02-47-21`
- 最新行政补录清单:
- `D:\AI Code\GuDingZiChan\行政手动补录清单-2026-03-23_03-07-12.xlsx`
## 当前行政补录清单统计
- `待确认部门`:23
- `缺少编码`:6
- `重复编码`:
- 重复编码数:5
- 重复记录数:10
- `待补使用人`:196
- `花名核对`:105
- `待补地址`:59
## 关于 196 条“待补使用人”的结论
- 其中 `193` 条原因是:`owner_not_in_active_synced_roster`
- 其中 `3` 条原因是:`multi_owner_cell`
- 已做过进一步核对:
- 这批花名按 `hr_employee_register.sys00-flowerName`
- `hr_employee_register.sys00-name`
- `hr_employee_register.sys02-realName`
- `hr_employee.name`
- 四个字段去查,都没有命中
- 当前结论:
- 这不是简单的“同步规则漏同步了当前员工”
- 更像是 Excel 里使用的是另一套历史花名/历史称呼
- 需要行政按“花名核对”工作表,把历史花名对应到当前在职员工
## 当前剩余 59 条待补地址的主要口径
- 空白:32
- `电子设备`:5
- `河北仓`:3
- `天安/杭州`:2
- `山东仓`:2
- `抓数用`:2
- 其余都是 1 条
## 剩余地址为什么先不继续自动处理
- `空白` 无法推断
- `电子设备`、`抓数用` 不是地点字段
- `河北仓`、`山东仓`、`睢宁仓`、`沭阳` 等更像真实外部仓点
- 可能应该新增标准地址
- 不适合硬映射到现有 `常州-仓库`
- `天安/杭州`、`杭州/天安` 是双地址写法
- 当前没有足够证据判断应该落到哪一个标准地址
## 如果后面继续推进,优先顺序建议
1. 行政先处理 `花名核对` 工作表,确认 105 个历史花名对应的当前人员
2. 行政确认 59 条待补地址里,哪些应新增为正式标准地址
3. 再决定是否补第二轮“带使用人回挂”或继续回填新增地址
## 安全边界
- 本次所有 live 操作都只针对固定资产系统
- 未操作 `/opt/shebeiguanli`
- 未操作任何 `shebeiguanli-*` 容器或其数据库

View File

@ -0,0 +1,572 @@
# 组织同步源表结构说明
更新时间:2026-03-19
适用项目:固定资产管理系统(`D:\AI Code\GuDingZiChan`)
状态:最新源表核查结论与同步实现基线
---
## 1. 文档目的
这份文档用于固定当前 SQL Server 人事源的真实情况,以及固定资产系统后续应采用的同步口径。
这次更新的重点不是“字段长什么样”,而是把下面几件事彻底说清楚:
- 哪张表现在更新及时
- 哪张表已经明显滞后
- 为什么系统里按旧口径只能算出 `653`
- 为什么用户在钉钉里看到大约 `773`
- 固定资产系统后续到底应该按哪套表和字段同步
---
## 2. 最新结论
先说最终结论:
- `dbo.hr_employee_register` 现在要作为**人员同步主口径**
- `dbo.hr_employee` 只作为**补充字段来源**
- `dbo.hr_groups` 只作为**历史参考 / 追溯参考**,不再作为当前业务部门主依据
这不是主观猜测,而是当前数据直接说明了这件事:
- `hr_employee` 最新工号只到 `JS24081502`
- `hr_employee_register` 最新工号已经到 `JS26031604`
- `hr_employee_register` 里存在大量 `2025`、`2026` 工号的新员工
- 这些新员工很多根本不在 `hr_employee`
- 这些新员工的业务部门名也有很多不在 `hr_groups` 一级树里
一句话概括:
- `hr_employee + hr_groups` 更像一套旧的、部分停留在 `2024-08` 左右的组织镜像
- `hr_employee_register` 更像当前仍在持续更新的人事登记口径
---
## 3. 三张表的新定位
### 3.1 `dbo.hr_employee_register`
新定位:**当前人员同步主表**
主要用途:
- 人员主键:`userid`
- 花名:`sys00-flowerName`
- 姓名:`sys00-name` / `sys02-realName`
- 工号:`sys00-jobNumber`
- 岗位:`sys00-position`
- 手机:`sys00-mobile`
- 员工状态:`sys01-employeeStatus`
- 业务部门:`sys00-dept`
- 主部门兜底:`sys00-mainDepts`
- 源追溯字段:`sys00-mainDeptId`、`sys00-deptIds`
后续同步时:
- 人员名单从这里出
- 部门名优先从这里取
- 是否正式员工也从这里判断
### 3.2 `dbo.hr_employee`
新定位:**补充字段表,不再做主名单表**
保留价值:
- `email`
- `manager_userid`
- `title`
- `extension`
- 某些历史账号还能补到 `name`、`mobile`
但它不再适合作为“谁该同步”的主判断依据。
### 3.3 `dbo.hr_groups`
新定位:**旧组织树参考表,不再做当前主部门树**
保留价值:
- 历史追溯
- 排查旧同步结果为什么会错
- 理解旧资产 / 旧部门引用来自哪里
不再适合作为:
- 当前前台业务部门树
- 当前人员主部门解析依据
- 当前人数口径判断依据
---
## 4. 当前已核实的关键数字
### 4.1 表规模
- `hr_employee`:`1050`
- `hr_groups`:`781`
- `hr_employee_register`:`1426`
### 4.2 `hr_employee` 与 `hr_employee_register` 对应情况
按 `userid` 核查:
- `hr_employee` 中能匹配到 `register` 的:`796`
- `hr_employee` 中匹配不到 `register` 的:`254`
- `register` 中存在但不在 `hr_employee` 的:`630`
这说明:
- 两张表不是同一套完整在职名单
- `hr_employee` 不能代表“当前全部在职内部员工”
### 4.3 `hr_employee` 口径为什么明显旧
已核实:
- `hr_employee` 最大工号:`JS24081502`
- `hr_employee_register` 最大工号:`JS26031604`
这说明:
- `hr_employee` 至少在“新人入职覆盖”这件事上,已经明显慢于 `register`
### 4.4 `register` 中内部人员分布
按 `hr_employee_register` 统计:
- `employeeType = 1, employeeStatus = 3`:`1322`
- `employeeType = 1, employeeStatus = 2`:`103`
- `employeeType = 2, employeeStatus = 3`:`1`
按“`sys00-dept` 不是空,且不属于 `供应商 / 快递公司 / 外部交流`”统计:
- 内部业务部门样本总数:`817`
- 其中 `status = 3` 的:`714`
### 4.5 为什么 `653` 和 `773` 差很多
按旧口径:
- 从 `hr_employee + hr_groups`
- 排除 `供应商 / 快递公司 / 外部交流`
只能得到:`653`
但继续往下拆后发现:
- `register` 里有 `290` 个“内部部门 + `status=3` + 不在 `hr_employee`”的人
- 这批人里,大量部门名根本不在 `hr_groups` 一级树里
最典型的新增部门包括:
- `龙晨品牌中心`
- `视觉中心`
- `品牌策划中心`
- `摄影中心`
- `商品企划部`
- `品牌中心`
仅这些“`register` 里有、`hr_groups` 里没有”的新部门人员,就接近 `116` 人,已经非常接近 `653` 与 `773` 之间的差值。
所以真正的问题不是“少排了一个外部树”,而是:
- 旧同步口径依赖了过旧的 `hr_employee + hr_groups`
- 当前真实内部员工和业务部门已经大量沉淀在 `hr_employee_register`
---
## 5. 当前组织同步实现基线
### 5.1 人员主名单来源
后续代码实现一律按:
- 主来源:`hr_employee_register`
- 补充来源:`LEFT JOIN hr_employee ON userid`
### 5.2 内部 / 外部过滤规则
当前默认排除的源部门名:
- `供应商`
- `快递公司`
- `外部交流`
判断方式:
- 先看 `sys00-dept`
- 如果 `sys00-dept` 明确是外部部门名,则直接排除
- 只有 `sys00-dept` 为空时,才再看 `sys00-mainDepts`
- `sys00-mainDepts` 只作为空主部门时的兜底参考,不用于覆盖外部主部门判断
### 5.3 正式员工与非正式员工规则
正式员工判断:
- `sys01-employeeStatus = '3'`
正式员工部门落地规则:
1. 优先 `sys00-dept`
2. 若为空,兜底 `sys00-mainDepts`
3. 若两者都为空,落到 `其他`
非正式员工规则:
- 不再直接丢弃
- 仍同步进系统
- 统一归入 `其他`
### 5.4 在职 / 停用判断
后续代码不再以 `hr_employee.active` 作为主依据。
默认规则:
- 若 `sys01-employeeStatus` 命中停用状态集合,则视为 `inactive`
- 默认停用状态集合仍保留环境变量控制
- 默认值:`5`
当前已核实的数据里主要是:
- `3`
- `2`
因此当前大部分 `register` 记录会被视为有效同步候选。
### 5.5 部门模型
本地 `department` 表继续只保留“业务部门”:
- `运营中心A`
- `客服中心`
- `仓储中心`
- `视觉中心`
- `龙晨品牌中心`
- `品牌策划中心`
- `摄影中心`
- `其他`
不再把 `hr_groups` 叶子节点灌进前台。
---
## 6. 字段使用建议
### 6.1 继续保留到本地 `person` 的字段
- `source_userid`
- `source_main_department_id`
- `source_dept_ids`
- `source_department_name`
- `source_main_department_name`
- `department_sync_bucket`
- `manager_source_userid`
- `source_employee_status`
- `last_synced_at`
### 6.2 字段优先级
姓名相关:
1. 花名:`sys00-flowerName`
2. 姓名:`sys00-name`
3. 真实姓名:`sys02-realName`
4. 再兜底 `hr_employee.name`
联系方式相关:
1. 手机:`sys00-mobile`
2. 再兜底 `hr_employee.mobile`
岗位相关:
1. `sys00-position`
2. 再兜底 `hr_employee.title`
邮箱 / 上级:
- 目前仍主要从 `hr_employee` 补
---
## 7. 需要特别记住的事实
1. `hr_employee_register` 已经不是“补充信息表”这么简单,它现在才是最新的人事主口径
2. `hr_employee` 仍有价值,但更像历史补充表
3. `hr_groups` 里明确混有外部组织树,不能直接拿整棵树做前台部门
4. `sys00-mainDeptId` 不能再当主部门依据
5. `sys00-dept` 是当前最接近业务真实视图的主部门字段
6. `sys00-mainDepts` 只能做正式员工空部门兜底
7. 当前代码若继续坚持 `hr_employee` 为主表,会持续漏掉大量 `2025/2026` 新员工
---
## 8. 后续建议
如果继续推进组织同步,建议顺序就是:
1. 代码改为 `register` 主表同步
2. 前端同步页文案改成新口径
3. 跑一次预览,核对:
- 人数
- 部门集合
- `其他` 人数
4. 再决定是否立刻正式同步到线上
如果后续还要继续逼近“钉钉约 773 人”的口径,下一步应继续核:
- `status = 2` 在业务上到底算不算应同步人员
- `龙晨品牌中心 / 品牌中心 / 品牌策划中心` 是否确属当前业务大部门
- 钉钉架构页展示是否有额外筛选
---
## 9. 当前最终判断
一句话最终判断:
- 这次组织同步的根因已经找到
- 不是筛选条件写错一点点
- 而是旧代码依赖了过旧的主表和组织树
后续固定资产系统应采用:
- `hr_employee_register` 作为主同步源
- `sys00-dept` 作为主业务部门
- `sys00-mainDepts` 作为正式员工兜底
- `hr_employee` 只做补充字段
- `hr_groups` 只做追溯参考
---
## 10. 2026-03-19 补充核查结论
本轮继续往下核以后,又确认了下面这些关键事实:
1. 当前差异的核心不是“少排了几个外部树”,而是源表更新时间和口径不一致
2. `hr_employee` 内部口径人数约 `653`
3. `hr_employee_register` 内部口径人数约 `817`
4. `hr_employee` 最大工号只到 `JS24081502`
5. `hr_employee_register` 最大工号已经到 `JS26031604`
这说明:
- `hr_employee` 明显缺少 `2025/2026` 的新员工数据
- `register` 已经包含大量更新的人事数据
- 继续拿 `hr_employee + hr_groups` 作为主同步口径,只会持续漏同步新人
---
## 11. 对“653 vs 773”差值的当前判断
当前最接近根因的判断是:
1. `653` 是旧口径下算出来的内部人数
2. 你在钉钉里看到的大约 `773`,更接近 `register` 这套新口径
3. 两者之间的缺口,主要来自:
- `register` 里新增的业务部门
- `register` 里新增的 `2025/2026` 新员工
目前已经明确出现的新业务部门包括:
- `龙晨品牌中心`
- `视觉中心`
- `品牌策划中心`
- `摄影中心`
- `商品企划部`
- `品牌中心`
这些部门在旧 `hr_groups` 一级树里并不完整存在,所以旧逻辑天然会漏。
---
## 12. 当前代码落地状态
截至 `2026-03-19`,项目代码已继续朝这个方向收口:
1. 同步页预览已明确标注当前口径为“登记表主口径”
2. 同步页会展示:
- `登记表 + 员工表匹配人数`
- `仅登记表命中人数`
- `formal / fallback / other` 三类人数
3. 后端排除外部部门时,已兼容读取:
- `HR_SYNC_EXCLUDED_DEPARTMENTS`
- `HR_SYNC_EXCLUDED_DEPARTMENT_NAMES`
这部分的目的不是改文案,而是让后续继续逼近真实人数时,能直接在系统里看到差异来自哪一批数据。
---
## 13. 2026-03-19 再次深挖“653 vs 773”后的新判断
这轮继续往下查以后,已经可以把问题再说得更具体一些。
### 13.1 只看 `register` 内部业务部门时的真实规模
按下面这个口径统计:
- `sys00-dept` 非空
- `sys00-dept` 不属于 `供应商 / 快递公司 / 外部交流`
得到:
- 总人数:`817`
- `employeeStatus = 3`:`714`
- `employeeStatus = 2`:`103`
也就是说,单纯把 `register` 当“全部应同步内部人员”,人数会明显高于你在钉钉里看到的大约 `773`。
### 13.2 旧树缺口已经可以量化
继续把 `register.sys00-dept` 和 `hr_groups.name` 直接对比后,发现:
- 仍能在 `hr_groups` 里找到部门名的:`612`
- 在 `hr_groups` 里根本找不到部门名的:`205`
这 `205` 人主要集中在下面这些新业务部门:
- `龙晨品牌中心`:`69`
- `视觉中心`:`62`
- `摄影中心`:`32`
- `品牌策划中心`:`17`
- `商品企划部`:`14`
- `效率部(懂业务的技术部)`:`8`
- `品牌中心`:`3`
这说明旧口径漏人的原因已经非常明确:
- 不只是旧表少更新
- 还因为当前真实业务部门里,已经有一批部门根本不在旧 `hr_groups` 主树口径里
### 13.3 当前最可疑的一批人:`业务支持` 里没有工号的 44 人
这轮最关键的新发现是:
- `业务支持` 总人数:`45`
- 其中没有工号的:`44`
- 唯一有工号的 1 人:`JS24070406`
更关键的是:
- `817 - 44 = 773`
也就是说,如果仅把这 `44` 个“`业务支持` 且无工号”的人暂时视为不进入钉钉统计口径的人群,人数会非常接近你看到的大约 `773`。
再继续拆以后,这 `44` 人本身又分成两类:
- `20` 人:`hr_employee` 里也存在,且仍是 `active = true`
- `24` 人:只在 `hr_employee_register` 里出现,旧员工表和旧组织树里都没有对应记录
对应到旧组织树:
- 有旧根部门 `业务支持` 的:`20`
- 完全没有旧组织根的:`24`
这 `44` 人的 `sys00-mainDepts` 又高度集中在:
- `门禁系统`:`16`
- `仓储支持`:`14`
- `山东项目组`:`8`
- `云杉对接`:`3`
- `钉钉技术`:`1`
- `软件支持`:`1`
- `视觉支持`:`1`
从命名上看,这一批更像:
- 支撑组
- 项目组
- 系统对接 / 技术支持账号
而不像标准业务部门下的普通员工口径。
### 13.4 不能简单按“没工号就全排除”
虽然“无工号”是一个很强的信号,但不能直接全局套用。
原因是:
- 内部业务部门里无工号的总人数有 `114`
- 其中 `龙晨品牌中心` 就有 `65`
- `业务支持` 有 `44`
- `品牌中心` 有 `3`
- `广州项目组` 有 `2`
如果按“无工号一律排除”,那会把人数直接压到 `703`,明显又偏低了。
所以当前更合理的判断不是:
- “没有工号就都不是内部员工”
而是:
- `业务支持` 这批无工号人员,极可能是一个特殊口径
- 需要单独处理,不能拿来代表全部无工号人员
### 13.5 对 `龙晨品牌中心` 的处理更新(用户实页复核后)
`龙晨品牌中心` 目前也很特殊:
- 总人数:`69`
- `register_only`:`69`
- 无工号:`65`
- `status = 3`:`66`
这部分在早期阶段之所以没有直接排掉,是因为当时只看总人数,担心一刀切后和你提到的 `773` 偏差更大。
但在 2026-03-19 进一步拿系统实际页面逐个反查后,已经有了更强证据:
- 你在“使用者列表”里连续翻页时,发现一整批明显不对的人名
- 这些样本反查后,全部落在 `sys00-dept = 龙晨品牌中心`
- 这批人只存在于 `hr_employee_register`
- `hr_employee` 里完全没有对应记录
- 页面样本里还出现了类似 `001`、`朱凤—财务管理部—会计` 这种明显不适合作为正式员工名册直接展示的账号
所以这一步可以把结论往前推进:
- `龙晨品牌中心` 不能再按“先保留观察”处理
- 在固定资产系统当前人员同步口径里,应按**特殊排除**处理
- 更稳妥的排除条件是:`sourceDepartmentName = 龙晨品牌中心` 且 `sourcePresence = register_only`
同性质的小批量异常还有:
- `品牌中心`:`3` 人
- 3 人全部 `status = 3`
- 3 人全部无工号
- 同样只在 `register` 里出现
这 3 人也应按同类特殊排除处理
### 13.6 当前最稳的中间结论
截至这轮核查,可以先固定下面这个判断:
1. `hr_employee + hr_groups` 的旧口径已经不可信,继续用只会持续漏人
2. `hr_employee_register` 仍然是最接近真实源的人事主口径
3. 但 `register` 不能直接“整表内部部门全量同步”,因为这样会到 `817`
4. 目前已经明确应从主同步口径里特殊排除的,不只有 `业务支持 + 无工号`
5. 还包括:
- `龙晨品牌中心(仅登记表命中)`
- `品牌中心(仅登记表命中)`
6. 如果只排掉 `业务支持 + 无工号`,虽然人数能接近 `773`,但页面实页仍会出现成批错误人员
7. 之前那种“只追人数接近 `773`”的口径已经不够,必须以页面实际人员正确性为优先
8. 原 `773` 的组成是:
- `status = 3`:`670`
- `status = 2`:`103`
9. 这说明“钉钉约 `773` 人”的口径,不能直接当成唯一目标值;正确的目标应该是“页面里的人员名单和真实组织一致”
### 13.7 下一步建议
最建议继续做的是:
1. 先把同步规则正式收敛为:
- `register` 主口径
- 业务部门主字段仍是 `sys00-dept`
- 对 `业务支持 + 无工号` 做特殊排除
- 对 `龙晨品牌中心(仅登记表命中)` 做特殊排除
- 对 `品牌中心(仅登记表命中)` 做特殊排除
2. 同步后重新让你从页面直接抽样验证
3. 如果还有成批错误人员,再继续反查它们是否又集中在新的异常部门线上

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,9 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
expires -1;
try_files $uri $uri/ /index.html;
}
@ -71,6 +74,9 @@ server {
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
expires -1;
try_files $uri $uri/ /index.html;
}

View File

@ -1,5 +1,11 @@
import api from './index'
import type { ApiResponse, PaginatedResponse, Department, DepartmentTreeNode } from '@/types'
import type {
ApiResponse,
PaginatedResponse,
Department,
DepartmentTreeNode,
OrganizationTreeResponse
} from '@/types'
export const getDepartmentOptions = async () => {
const response = await api.get<ApiResponse<Department[]>>('/departments/options')
@ -11,6 +17,11 @@ export const getDepartmentTree = async () => {
return response.data
}
export const getOrganizationTree = async () => {
const response = await api.get<ApiResponse<OrganizationTreeResponse>>('/departments/organization-tree')
return response.data
}
export const getDepartmentList = async (params: {
page?: number
pageSize?: number

View File

@ -0,0 +1,68 @@
import api from './index'
import type {
ApiResponse,
InventoryTaskDetail,
InventoryTaskItem,
InventoryTaskListItem,
InventoryTaskPreview,
PaginatedResponse
} from '@/types'
export interface InventoryTaskPreviewPayload {
department_id?: number | null
asset_type_id?: number | null
}
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
title: string
description?: string
}
export const previewInventoryTask = async (data: InventoryTaskPreviewPayload) => {
const response = await api.post<ApiResponse<InventoryTaskPreview>>('/inventory/tasks/preview', data)
return response.data
}
export const createInventoryTask = async (data: CreateInventoryTaskPayload) => {
const response = await api.post<ApiResponse<InventoryTaskListItem>>('/inventory/tasks', data)
return response.data
}
export const getInventoryTaskList = async (params: {
page?: number
pageSize?: number
}) => {
const response = await api.get<ApiResponse<PaginatedResponse<InventoryTaskListItem>>>('/inventory/tasks', {
params
})
return response.data
}
export const getInventoryTaskDetail = async (id: number) => {
const response = await api.get<ApiResponse<InventoryTaskDetail>>(`/inventory/tasks/${id}`)
return response.data
}
export const closeInventoryTask = async (id: number) => {
const response = await api.post<ApiResponse<null>>(`/inventory/tasks/${id}/close`)
return response.data
}
export const getInventoryTaskUnassignedItems = async (id: number) => {
const response = await api.get<ApiResponse<InventoryTaskItem[]>>(`/inventory/tasks/${id}/unassigned`)
return response.data
}
export const assignInventoryTaskUnassignedItem = async (
taskId: number,
itemId: number,
personId: number
) => {
const response = await api.post<ApiResponse<null>>(
`/inventory/tasks/${taskId}/unassigned/${itemId}/assign`,
{
person_id: personId
}
)
return response.data
}

View File

@ -0,0 +1,105 @@
import axios from 'axios'
import type { AxiosResponse } from 'axios'
import type {
ApiResponse,
InventoryTaskItem,
PublicInventoryItemsResponse,
PublicInventoryParticipantOption,
PublicInventorySessionData,
PublicInventoryTaskInfo
} from '@/types'
const resolvePublicInventoryBaseUrl = () => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
return `${baseUrl.replace(/\/$/, '')}/public/inventory`
}
const publicInventoryApi = axios.create({
baseURL: resolvePublicInventoryBaseUrl(),
timeout: 10000,
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
publicInventoryApi.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const data = response.data as ApiResponse
if (data?.code === 200) {
return response
}
return Promise.reject(new Error(data?.message || '请求失败'))
},
(error) => {
const message =
error?.response?.data?.message ||
error?.response?.data?.error ||
error?.message ||
'请求失败'
return Promise.reject(new Error(message))
}
)
export const getPublicInventoryTask = async (token: string) => {
const response = await publicInventoryApi.get<ApiResponse<PublicInventoryTaskInfo>>(`/tasks/${token}`)
return response.data
}
export const searchPublicInventoryParticipants = async (token: string, keyword?: string) => {
const response = await publicInventoryApi.get<ApiResponse<PublicInventoryParticipantOption[]>>(
`/tasks/${token}/participants`,
{
params: {
keyword: keyword || undefined
}
}
)
return response.data
}
export const confirmPublicInventoryParticipant = async (token: string, participantId: number) => {
const response = await publicInventoryApi.post<
ApiResponse<{
task_id: number
task_title: string
participant_id: number
participant_name: string
participant_nickname: string
}>
>(`/tasks/${token}/participants/confirm`, {
participant_id: participantId
})
return response.data
}
export const getPublicInventoryMe = async () => {
const response = await publicInventoryApi.get<ApiResponse<PublicInventorySessionData>>('/me')
return response.data
}
export const getPublicInventoryItems = async () => {
const response = await publicInventoryApi.get<ApiResponse<PublicInventoryItemsResponse>>('/me/items')
return response.data
}
export const submitPublicInventoryScan = async (equipmentCode: string) => {
const response = await publicInventoryApi.post<ApiResponse<InventoryTaskItem>>('/me/scan', {
equipment_code: equipmentCode
})
return response.data
}
export const submitPublicInventoryException = async (
itemId: number,
data: {
exception_type: 'not_found' | 'not_with_me'
exception_note?: string
}
) => {
const response = await publicInventoryApi.post<ApiResponse<null>>(
`/me/items/${itemId}/exception`,
data
)
return response.data
}

View File

@ -2,18 +2,13 @@
<Teleport to="body">
<div
v-if="modelValue"
class="sheet-backdrop z-[80]"
class="fixed inset-0 z-[80] flex items-end justify-center overflow-y-auto bg-black/75 p-3 sm:items-center sm:p-4"
@click.self="close"
>
<div class="sheet-panel max-w-xl overflow-hidden p-0">
<div class="border-b border-border px-4 py-4 sm:px-5">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text-primary">{{ title }}</h3>
<p v-if="description" class="text-sm leading-6 text-text-secondary">
{{ description }}
</p>
</div>
<div class="flex max-h-[calc(100dvh-1.5rem)] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-border bg-bg-secondary shadow-2xl sm:max-h-[calc(100dvh-2rem)]">
<div class="shrink-0 border-b border-border bg-bg-secondary px-4 py-3 sm:px-5">
<div class="flex items-center justify-between gap-4">
<h3 class="text-base font-semibold text-text-primary">{{ title }}</h3>
<button type="button" class="btn-icon" @click="close" aria-label="关闭扫码窗口">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 6l12 12M18 6L6 18" />
@ -89,7 +84,7 @@
{{ imageError }}
</div>
<div class="panel">
<div class="rounded-2xl border border-border bg-bg-elevated px-4 py-4">
<label class="mb-2 block text-sm font-medium text-text-primary">手动输入</label>
<div class="flex flex-col gap-3 sm:flex-row">
<input
@ -104,7 +99,8 @@
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -10,19 +10,19 @@
<!-- 侧边栏 -->
<aside
class="fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col border-r border-border bg-bg-secondary transition-transform duration-200 lg:w-64 lg:max-w-none lg:translate-x-0"
class="fixed inset-y-0 left-0 z-50 flex w-64 max-w-[80vw] flex-col border-r border-border bg-bg-secondary transition-transform duration-200 lg:w-56 lg:max-w-none lg:translate-x-0"
:class="mobileNavOpen ? 'translate-x-0' : '-translate-x-full'"
>
<!-- Logo 区域 -->
<div class="flex items-center justify-between border-b border-border p-4 lg:block">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border bg-bg-elevated">
<div class="flex items-center justify-between border-b border-border p-3 lg:block">
<div class="flex items-center gap-2.5">
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg border border-border bg-bg-elevated">
<img v-if="siteStore.logoUrl" :src="siteStore.logoUrl" alt="logo" class="h-full w-full object-cover" />
<span v-else class="text-sm font-bold text-white">资</span>
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-sm font-semibold text-text-primary">{{ siteStore.siteName }}</h1>
<p class="text-xs text-text-tertiary">固定资产管理</p>
<p class="text-xs text-text-tertiary">固定资产</p>
</div>
</div>
<button type="button" class="btn-icon lg:hidden" @click="mobileNavOpen = false" aria-label="关闭导航">
@ -31,7 +31,7 @@
</div>
<!-- 导航菜单 -->
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<nav class="flex-1 space-y-0.5 overflow-y-auto p-2">
<router-link
v-for="item in visibleNavItems"
:key="item.path"
@ -46,9 +46,9 @@
</nav>
<!-- 用户区域 - 固定在底部 -->
<div class="shrink-0 border-t border-border p-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-bg-elevated">
<div class="shrink-0 border-t border-border p-3">
<div class="flex items-center gap-2.5">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-bg-elevated">
<span class="text-sm text-text-secondary">{{ userInitials }}</span>
</div>
<div class="min-w-0 flex-1">
@ -56,47 +56,47 @@
{{ authStore.user?.name || authStore.user?.username || '用户' }}
</p>
<p class="text-xs text-text-tertiary">
{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}
{{ authStore.user?.role === 'admin' ? '管理员' : '用户' }}
</p>
</div>
</div>
<div class="mt-4 grid gap-2" :class="authStore.user?.role === 'admin' ? 'grid-cols-2' : 'grid-cols-1'">
<div class="mt-3 grid gap-1.5" :class="authStore.user?.role === 'admin' ? 'grid-cols-2' : 'grid-cols-1'">
<button
v-if="authStore.user?.role === 'admin'"
type="button"
class="btn-secondary min-h-[38px] px-3 text-xs"
class="btn-secondary min-h-[34px] px-2.5 text-xs"
@click="router.push('/settings')"
>
系统设置
设置
</button>
<button
type="button"
class="btn-secondary min-h-[38px] px-3 text-xs"
class="btn-secondary min-h-[34px] px-2.5 text-xs"
@click="handleLogout"
>
退出登录
退出
</button>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="flex min-w-0 flex-1 flex-col lg:ml-64">
<main class="flex min-w-0 flex-1 flex-col lg:ml-56">
<!-- 顶部栏 -->
<header class="sticky top-0 z-30 border-b border-border bg-bg-secondary/95 backdrop-blur">
<div class="flex items-center justify-between gap-4 px-4 py-4 sm:px-6">
<div class="flex min-w-0 items-center gap-3">
<div class="flex items-center justify-between gap-4 px-3 py-3 sm:px-4">
<div class="flex min-w-0 items-center gap-2.5">
<button type="button" class="btn-icon lg:hidden" @click="mobileNavOpen = true" aria-label="打开导航">
<Bars3Icon class="h-5 w-5" />
</button>
<div class="min-w-0">
<h2 class="truncate text-lg font-semibold text-text-primary">{{ currentSection }}</h2>
<p v-if="currentSubtitle" class="mt-1 text-sm text-text-tertiary">{{ currentSubtitle }}</p>
<h2 class="truncate text-base font-semibold text-text-primary">{{ currentSection }}</h2>
<p v-if="currentSubtitle" class="text-xs text-text-tertiary">{{ currentSubtitle }}</p>
</div>
</div>
<div class="hidden items-center gap-3 lg:flex">
<div class="hidden items-center gap-2 lg:flex">
<button
v-if="authStore.user?.role === 'admin'"
type="button"
@ -107,20 +107,20 @@
<BellIcon class="h-5 w-5" />
<span
v-if="notificationStore.unreadCount > 0"
class="absolute -right-1 -top-1 min-w-[18px] rounded-full bg-status-error px-1.5 py-0.5 text-center text-[10px] font-semibold leading-none text-white"
class="absolute -right-1 -top-1 min-w-[16px] rounded-full bg-status-error px-1 py-0.5 text-center text-[10px] font-semibold leading-none text-white"
>
{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}
</span>
</button>
<div class="rounded-full border border-border px-3 py-1.5 text-xs text-text-tertiary">
{{ authStore.user?.role === 'admin' ? '管理员模式' : '协作模式' }}
<div class="rounded-full border border-border px-2.5 py-1 text-xs text-text-tertiary">
{{ authStore.user?.role === 'admin' ? '管理员' : '协作' }}
</div>
</div>
</div>
</header>
<!-- 页面内容 -->
<div class="flex-1 overflow-auto px-4 py-5 sm:px-6">
<div class="flex-1 overflow-auto px-3 py-4 sm:px-4">
<router-view />
</div>
</main>
@ -137,6 +137,7 @@ import { useSiteStore } from '@/stores/site'
import {
HomeIcon,
CubeIcon,
ClipboardDocumentCheckIcon,
ArrowRightIcon,
ChartBarIcon,
BellIcon,
@ -155,10 +156,11 @@ const mobileNavOpen = ref(false)
const navItems = [
{ path: '/dashboard', title: '首页', icon: HomeIcon },
{ path: '/equipment', title: '资产管理', icon: CubeIcon, requiresAdmin: true },
{ path: '/inventory', title: '资产盘点', icon: ClipboardDocumentCheckIcon, requiresAdmin: true },
{ path: '/borrow', title: '使用管理', icon: ArrowRightIcon },
{ path: '/statistics', title: '统计分析', icon: ChartBarIcon },
{ path: '/notifications', title: '站内消息', icon: BellIcon, requiresAdmin: true },
{ path: '/settings', title: '系统设置', icon: Cog6ToothIcon, requiresAdmin: true }
{ path: '/statistics', title: '统计', icon: ChartBarIcon },
{ path: '/notifications', title: '消息', icon: BellIcon, requiresAdmin: true },
{ path: '/settings', title: '设置', icon: Cog6ToothIcon, requiresAdmin: true }
]
const visibleNavItems = computed(() => {
@ -175,6 +177,9 @@ const currentSubtitle = computed(() => {
if (route.path === '/dashboard') return '资产运行总览'
if (route.path === '/equipment') return '资产维护与盘点'
if (route.path.startsWith('/equipment/')) return '移动录入与现场维护'
if (route.path === '/inventory') return '任务范围与现场进度'
if (route.path === '/inventory/create') return '生成新的盘点任务'
if (route.path.startsWith('/inventory/')) return '查看盘点人、资产与异常结果'
if (route.path.startsWith('/borrow')) return '借还管理'
if (route.path.startsWith('/return/')) return '扫码归还'
if (route.path === '/statistics') return '数据分析'

View File

@ -8,6 +8,8 @@ import { useAuthStore } from './stores/auth'
import api from './api'
import type { User } from './types'
const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/'
const bootstrap = async () => {
const app = createApp(App)
const pinia = createPinia()
@ -17,6 +19,7 @@ const bootstrap = async () => {
const authStore = useAuthStore()
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX)
if (authMode === 'local') {
try {
@ -55,6 +58,12 @@ const bootstrap = async () => {
return
}
if (isPublicInventoryRoute) {
app.use(router)
app.mount('#app')
return
}
try {
const authenticated = await keycloak.init({
onLoad: 'login-required',

View File

@ -10,6 +10,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/inventory/task/:token',
name: 'PublicInventoryTask',
component: () => import('../views/inventory/PublicInventoryTask.vue'),
meta: { requiresAuth: false, title: '手机盘点' }
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
@ -64,6 +70,24 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/statistics/Statistics.vue'),
meta: { title: '统计分析' }
},
{
path: 'inventory',
name: 'InventoryList',
component: () => import('../views/inventory/InventoryList.vue'),
meta: { title: '资产盘点', requiresAdmin: true }
},
{
path: 'inventory/create',
name: 'InventoryCreate',
component: () => import('../views/inventory/InventoryCreate.vue'),
meta: { title: '新建盘点任务', requiresAdmin: true }
},
{
path: 'inventory/:id',
name: 'InventoryDetail',
component: () => import('../views/inventory/InventoryDetail.vue'),
meta: { title: '盘点任务详情', requiresAdmin: true }
},
{
path: 'notifications',
name: 'Notifications',
@ -88,13 +112,17 @@ const router = createRouter({
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
const isAuthenticated =
authMode === 'local'
? authStore.isAuthenticated
: Boolean(keycloak.authenticated && authStore.isAuthenticated)
if (to.path === '/login' && authStore.isAuthenticated) {
if (to.path === '/login' && isAuthenticated) {
next('/dashboard')
return
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
if (to.meta.requiresAuth && !isAuthenticated) {
if (authMode === 'local') {
next('/login')
} else {

View File

@ -127,6 +127,18 @@
transform: none;
}
.btn-secondary--highlight {
border-color: rgba(245, 158, 11, 0.28);
background: rgba(245, 158, 11, 0.1);
color: #fcd34d;
}
.btn-secondary--highlight:hover {
border-color: rgba(245, 158, 11, 0.46);
background: rgba(245, 158, 11, 0.16);
color: #ffffff;
}
.btn-danger {
@apply inline-flex min-h-[44px] items-center justify-center gap-2 px-5 py-2.5 text-sm font-medium text-white transition-all duration-150;
border-radius: var(--radius-md);
@ -166,15 +178,15 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
padding: var(--space-5);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.18);
padding: var(--space-4);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
}
.panel {
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
padding: var(--space-4);
padding: var(--space-3);
}
.input {
@ -246,12 +258,12 @@
.page-header {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-5);
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
background: var(--bg-secondary);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.18);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
}
.page-kicker {
@ -280,26 +292,26 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
padding: var(--space-4);
padding: var(--space-3);
}
.page-meta__label {
font-size: 11px;
letter-spacing: 0.05em;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.page-meta__value {
margin-top: var(--space-1);
font-size: 20px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.page-meta__subvalue {
margin-top: 2px;
font-size: 12px;
font-size: 11px;
color: var(--text-secondary);
}
@ -312,26 +324,34 @@
.filter-bar {
display: flex;
flex-direction: column;
gap: var(--space-4);
gap: var(--space-3);
}
.filter-bar__inputs {
display: grid;
gap: var(--space-3);
flex: 1;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.filter-bar__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
flex-wrap: nowrap;
gap: var(--space-2);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.filter-bar__actions::-webkit-scrollbar {
display: none;
}
.list-header {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.table-container {
@ -347,19 +367,20 @@
}
.data-table thead th {
padding: var(--space-4);
padding: var(--space-3);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 11px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
letter-spacing: 0.04em;
text-transform: uppercase;
text-align: left;
white-space: nowrap;
}
.data-table tbody td {
padding: var(--space-4);
padding: var(--space-3);
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 14px;
@ -375,7 +396,7 @@
}
.badge {
@apply inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium;
@apply inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium;
border-radius: var(--radius-full);
}
@ -430,7 +451,7 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
padding: var(--space-4);
padding: var(--space-3);
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
}
@ -487,8 +508,8 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
background: var(--bg-secondary);
padding: var(--space-5);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.42);
padding: var(--space-4);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
}
.mobile-sticky-actions {
@ -505,12 +526,12 @@
.scanner-hero {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid rgba(16, 163, 127, 0.2);
border-radius: var(--radius-xl);
background: var(--bg-secondary);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.18);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
}
.scanner-hero__stats {
@ -523,7 +544,7 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
padding: var(--space-4);
padding: var(--space-3);
}
.scanner-hero__stat-label {
@ -633,7 +654,7 @@
border: 1px dashed var(--border-color);
border-radius: var(--radius-xl);
background: var(--bg-secondary);
padding: var(--space-8) var(--space-5);
padding: var(--space-6) var(--space-4);
text-align: center;
color: var(--text-secondary);
}
@ -694,6 +715,18 @@
color: #ffffff;
}
.table-action--highlight {
border-color: rgba(245, 158, 11, 0.22);
background: rgba(245, 158, 11, 0.08);
color: #fcd34d;
}
.table-action--highlight:hover {
border-color: rgba(245, 158, 11, 0.42);
background: rgba(245, 158, 11, 0.14);
color: #ffffff;
}
.table-action--danger {
border-color: rgba(239, 68, 68, 0.22);
background: rgba(239, 68, 68, 0.08);
@ -748,7 +781,7 @@
}
.filter-bar__inputs {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
}

View File

@ -80,6 +80,30 @@ export interface DepartmentTreeNode {
last_synced_at?: string | null
}
export interface OrganizationDepartmentTreeNode extends DepartmentTreeNode {
direct_person_count: number
total_person_count: number
people: Person[]
children: OrganizationDepartmentTreeNode[]
}
export interface OrganizationTreeSummary {
department_total: number
active_department_total: number
person_total: number
active_person_total: number
inactive_person_total: number
synced_person_total: number
local_person_total: number
unassigned_person_total: number
}
export interface OrganizationTreeResponse {
departments: OrganizationDepartmentTreeNode[]
unassigned_people: Person[]
summary: OrganizationTreeSummary
}
// 设备接口
export interface Equipment {
id: number
@ -115,6 +139,7 @@ export interface Equipment {
equipment_model?: string
unit_price?: number
} | null
current_borrow?: EquipmentCurrentBorrow | null
children?: Array<{
id: number
equipment_code: string
@ -124,11 +149,28 @@ export interface Equipment {
unit_price?: number
status?: string
mount_status?: string
current_borrow?: EquipmentCurrentBorrow | null
}>
created_at: string
updated_at: string
}
export interface EquipmentCurrentBorrow {
id: number
person_id: number
person_name: string
person_nickname: string
person_identifier?: string | null
borrow_time: string
status: string
person?: {
id: number
nickname: string
real_name: string
id_number?: string
} | null
}
// 借用记录接口
export interface Person {
id: number
@ -149,6 +191,9 @@ export interface Person {
source_system?: string | null
source_main_department_id?: number | null
source_dept_ids?: string | null
source_department_name?: string | null
source_main_department_name?: string | null
department_sync_bucket?: string | null
manager_source_userid?: string | null
source_employee_status?: string | null
last_synced_at?: string | null
@ -181,6 +226,252 @@ export interface BorrowRecord {
updated_at: string
}
export type InventoryTaskStatus = 'active' | 'closed'
export type InventoryParticipantStatus = 'pending' | 'in_progress' | 'completed'
export type InventoryItemCheckStatus = 'pending' | 'checked' | 'exception'
export type InventoryExceptionType = 'not_found' | 'not_with_me'
export interface InventoryScopeEntity {
id: number
name: string
}
export interface InventoryTaskScopeSnapshot {
scope_department: InventoryScopeEntity | null
scope_asset_type: InventoryScopeEntity | null
participants_count: number
items_count: number
unassigned_count: number
generated_at: string
}
export interface InventoryPreviewDepartmentBreakdown {
department_name: string
total: number
assigned: number
unassigned: number
}
export interface InventoryPreviewAssetTypeBreakdown {
asset_type_name: string
total: number
}
export interface InventoryPreviewParticipantSample {
person_id: number
person_name: string
person_nickname: string
person_source_userid: string | null
department_id: number | null
department_name: string | null
}
export interface InventoryPreviewItemSample {
participant_person_id: number | null
equipment_id: number
equipment_code: string
equipment_name: string
equipment_model: string | null
equipment_type: string
asset_type_id: number | null
asset_type_name: string | null
snapshot_department_id: number | null
snapshot_department_name: string | null
snapshot_person_id: number | null
snapshot_person_name: string | null
snapshot_person_nickname: string | null
snapshot_status: string | null
source_borrow_record_id: number | null
is_unassigned: boolean
}
export interface InventoryTaskPreview {
scope_department: InventoryScopeEntity | null
scope_asset_type: InventoryScopeEntity | null
participants_count: number
items_count: number
unassigned_count: number
department_breakdown: InventoryPreviewDepartmentBreakdown[]
asset_type_breakdown: InventoryPreviewAssetTypeBreakdown[]
participants_sample: InventoryPreviewParticipantSample[]
items_sample: InventoryPreviewItemSample[]
}
export interface InventoryTaskListItem {
id: number
title: string
description?: string | null
status: InventoryTaskStatus | string
public_token: string
scope_department_id?: number | null
scope_department_name?: string | null
scope_asset_type_id?: number | null
scope_asset_type_name?: string | null
participants_count: number
items_count: number
completed_count: number
exception_count: number
unassigned_count: number
created_by: number
created_by_name?: string | null
closed_at?: string | null
created_at: string
updated_at: string
}
export interface InventoryTaskParticipant {
id: number
task_id: number
person_id: number | null
person_name: string
person_nickname: string
person_source_userid?: string | null
department_id: number | null
department_name: string | null
status: InventoryParticipantStatus | string
confirmed_at?: string | null
last_action_at?: string | null
created_at: string
updated_at: string
}
export interface InventoryTaskItem {
id: number
task_id: number
participant_id: number | null
equipment_id: number
equipment_code: string
equipment_name: string
equipment_model: string | null
equipment_type: string
asset_type_id: number | null
asset_type_name: string | null
snapshot_department_id: number | null
snapshot_department_name: string | null
snapshot_person_id: number | null
snapshot_person_name: string | null
snapshot_person_nickname: string | null
snapshot_status: string | null
source_borrow_record_id: number | null
is_unassigned: boolean
check_status: InventoryItemCheckStatus | string
checked_by_participant_id: number | null
checked_at?: string | null
exception_type?: InventoryExceptionType | string | null
exception_note?: string | null
created_at: string
updated_at: string
}
export interface InventoryTaskAction {
id: number
task_id: number
participant_id?: number | null
item_id?: number | null
action_type: string
action_payload?: string | null
ip_address?: string | null
user_agent?: string | null
operator_user_id?: number | null
created_at: string
}
export interface InventoryTaskDepartmentBreakdown {
department_name: string
total: number
checked: number
exception: number
pending: number
}
export interface InventoryTaskAssetTypeBreakdown {
asset_type_name: string
total: number
checked: number
exception: number
pending: number
}
export interface InventoryTaskDetail extends InventoryTaskListItem {
scope_snapshot: InventoryTaskScopeSnapshot | null
participants: InventoryTaskParticipant[]
items: InventoryTaskItem[]
recent_actions: InventoryTaskAction[]
department_breakdown: InventoryTaskDepartmentBreakdown[]
asset_type_breakdown: InventoryTaskAssetTypeBreakdown[]
}
export interface PublicInventoryTaskInfo {
id: number
title: string
description?: string | null
status: InventoryTaskStatus | string
scope_department_name?: string | null
scope_asset_type_name?: string | null
participants_count: number
items_count: number
completed_count: number
exception_count: number
unassigned_count: number
created_at: string
closed_at?: string | null
}
export interface PublicInventoryParticipantOption {
id: number
person_id: number | null
person_name: string
person_nickname: string
department_name?: string | null
status: InventoryParticipantStatus | string
confirmed_at?: string | null
}
export interface PublicInventorySessionTask {
id: number
title: string
status: InventoryTaskStatus | string
scope_department_name?: string | null
scope_asset_type_name?: string | null
completed_count: number
exception_count: number
items_count: number
closed_at?: string | null
}
export interface PublicInventorySessionData {
task: PublicInventorySessionTask
participant: InventoryTaskParticipant
}
export interface PublicInventoryItemsResponse {
items: InventoryTaskItem[]
summary: {
total: number
pending: number
checked: number
exception: number
}
}
export type InventoryPreviewResponse = InventoryTaskPreview
export interface InventoryTaskListResponse extends PaginatedResponse<InventoryTaskListItem> {}
export type InventoryPublicTaskInfo = PublicInventoryTaskInfo
export type InventoryPublicParticipantOption = PublicInventoryParticipantOption
export interface InventoryPublicConfirmResponse {
task_id: number
task_title: string
participant_id: number
participant_name: string
participant_nickname: string
}
export type InventoryPublicMeResponse = PublicInventorySessionData
export type InventoryPublicItemsResponse = PublicInventoryItemsResponse
// 用户接口
export interface User {
id: number | string
@ -255,22 +546,40 @@ export interface ValueStatistics {
}
export interface HrSyncPreview {
source_mode: 'register_primary' | string
departments: {
total: number
raw_total: number
sample: Array<{
sourceGroupId: number
name: string
sourcePersonCount: number
buckets: Array<'formal' | 'fallback' | 'other'>
}>
raw_sample?: Array<{
sourceGroupId: number | null
sourceParentGroupId: number | null
name: string
}>
}
persons: {
total: number
source_presence_summary: Record<'employee_and_register' | 'register_only', number>
department_bucket_summary: Record<'formal' | 'fallback' | 'other', number>
special_exclusion_summary: Record<
'business_support_blank_job_number' | 'longchen_brand_center_register_only' | 'brand_center_register_only',
number
>
sample: Array<{
sourceUserId: string
nickname: string
realName: string
departmentName: string | null
sourceDepartmentName: string | null
sourceMainDepartmentName: string | null
sourceJobNumber?: string | null
resolvedDepartmentName: string
departmentSyncBucket: 'formal' | 'fallback' | 'other'
sourcePresence: 'employee_and_register' | 'register_only'
position: string | null
sourceMainDepartmentId: number | null
sourceDeptIds: string | null
@ -299,6 +608,16 @@ export interface HrSyncResult {
updated: number
deactivated: number
}
equipment_department_migration?: {
migrated: number
unresolved: number
samples: Array<{
equipment_id: number
equipment_code: string
equipment_name: string
legacy_department_name: string | null
}>
}
sync_job_id?: number
changes_count?: number
}

View File

@ -1,106 +1,79 @@
<template>
<div class="min-h-screen bg-bg-primary">
<div class="mx-auto flex min-h-screen w-full max-w-6xl items-center px-4 py-6 sm:px-6 lg:px-8">
<div class="grid w-full gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<section class="hidden overflow-hidden rounded-[32px] border border-border bg-bg-secondary p-8 shadow-[0_24px_80px_rgba(0,0,0,0.28)] lg:block">
<div class="flex h-full flex-col justify-between">
<div>
<div class="inline-flex items-center rounded-full border border-border bg-bg-elevated px-3 py-1 text-xs text-text-secondary">
Asset Workflow
</div>
<h1 class="mt-6 max-w-xl text-4xl font-semibold leading-tight text-text-primary">
{{ siteName }}
</h1>
</div>
<div class="min-h-screen bg-bg-primary flex items-center justify-center px-4 py-4">
<section class="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-bg-secondary shadow-[0_12px_40px_rgba(0,0,0,0.18)]">
<div class="border-b border-border px-5 py-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-border bg-bg-elevated">
<span v-if="!siteStore.logoUrl" class="text-lg font-semibold text-text-primary">资</span>
<img v-else :src="siteStore.logoUrl" class="h-full w-full object-cover" />
</div>
</section>
<section class="overflow-hidden rounded-[28px] border border-border bg-bg-secondary shadow-[0_20px_60px_rgba(0,0,0,0.24)]">
<div class="border-b border-border px-6 py-6 sm:px-8">
<div class="flex items-center gap-4">
<div class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl border border-border bg-bg-elevated">
<span v-if="!siteStore.logoUrl" class="text-2xl font-semibold text-text-primary">资</span>
<img v-else :src="siteStore.logoUrl" class="h-full w-full object-cover" />
</div>
<div>
<p class="text-xs uppercase tracking-[0.18em] text-text-tertiary">Sign In</p>
<h2 class="mt-2 text-2xl font-semibold text-text-primary">{{ siteName }}</h2>
<p class="mt-1 text-sm text-text-secondary">固定资产管理系统</p>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-[0.12em] text-text-tertiary">Sign In</p>
<h2 class="mt-0.5 text-xl font-semibold text-text-primary">{{ siteName }}</h2>
</div>
<div class="px-6 py-6 sm:px-8 sm:py-8">
<form v-if="isLocalAuth" @submit.prevent="handleLocalLogin" class="space-y-5">
<div class="field-stack">
<label class="field-label">用户名</label>
<input
v-model.trim="username"
type="text"
class="input"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div class="field-stack">
<label class="field-label">密码</label>
<input
v-model="password"
type="password"
class="input"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div>
<div v-if="errorText" class="rounded-2xl border border-status-error/30 bg-status-error/10 px-4 py-3 text-sm text-status-error">
{{ errorText }}
</div>
<button
type="submit"
class="btn-primary w-full justify-center"
:disabled="loading || !canSubmit"
>
{{ loading ? '登录中...' : '进入系统' }}
</button>
<p class="text-center text-xs leading-6 text-text-tertiary">
登录后建议把当前页面加入手机浏览器主页,现场扫码会更顺手。
</p>
</form>
<div v-else class="space-y-5">
<div class="rounded-2xl border border-border bg-bg-elevated px-4 py-4">
<p class="text-sm font-medium text-text-primary">企业统一认证</p>
<p class="mt-2 text-sm leading-6 text-text-secondary">
当前站点已接入企业账号体系,点击下方按钮后会跳转到认证中心完成登录。
</p>
</div>
<button
type="button"
class="btn-primary w-full justify-center"
:disabled="loading"
@click="handleLogin"
>
{{ loading ? '跳转中...' : '使用企业账号登录' }}
</button>
<p class="text-center text-xs leading-6 text-text-tertiary">
认证服务由 {{ keycloakHostname }} 提供
</p>
</div>
</div>
<div class="border-t border-border px-6 py-4 text-center text-xs text-text-tertiary sm:px-8">
© {{ currentYear }} {{ siteName }}
</div>
</section>
</div>
</div>
</div>
<div class="px-5 py-5">
<form v-if="isLocalAuth" @submit.prevent="handleLocalLogin" class="space-y-4">
<div class="field-stack">
<label class="field-label">用户名</label>
<input
v-model.trim="username"
type="text"
class="input"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div class="field-stack">
<label class="field-label">密码</label>
<input
v-model="password"
type="password"
class="input"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div>
<div v-if="errorText" class="rounded-xl border border-status-error/30 bg-status-error/10 px-3 py-2 text-sm text-status-error">
{{ errorText }}
</div>
<button
type="submit"
class="btn-primary w-full justify-center"
:disabled="loading || !canSubmit"
>
{{ loading ? '登录中...' : '进入系统' }}
</button>
</form>
<div v-else class="space-y-4">
<div class="rounded-xl border border-border bg-bg-elevated px-3 py-3">
<p class="text-sm font-medium text-text-primary">企业统一认证</p>
<p class="mt-1 text-sm text-text-secondary">
点击按钮跳转至企业认证中心登录。
</p>
</div>
<button
type="button"
class="btn-primary w-full justify-center"
:disabled="loading"
@click="handleLogin"
>
{{ loading ? '跳转中...' : '使用企业账号登录' }}
</button>
</div>
</div>
<div class="border-t border-border px-5 py-3 text-center text-xs text-text-tertiary">
© {{ currentYear }} {{ siteName }}
</div>
</section>
</div>
</template>
@ -121,17 +94,6 @@ const password = ref('')
const errorText = ref('')
const siteStore = useSiteStore()
const keycloakBaseUrl = import.meta.env.VITE_KEYCLOAK_URL ?? ''
const keycloakHostname = computed(() => {
if (!keycloakBaseUrl) return '认证中心'
try {
return new URL(keycloakBaseUrl).host
} catch {
return keycloakBaseUrl
}
})
const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak'
const isLocalAuth = computed(() => authMode === 'local')
const siteName = computed(() => siteStore.siteName || '固定资产管理系统')

View File

@ -5,11 +5,11 @@
<div>
<p class="page-kicker">Usage Flow</p>
<h2 class="page-title">借用与归还管理</h2>
<div class="mt-5 flex flex-wrap gap-3">
<div class="mt-3 flex flex-wrap gap-2">
<router-link to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
<router-link to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
<button class="btn-secondary text-sm" :disabled="exporting" @click="handleExport">
{{ exporting ? '导出中...' : '导出借用记录' }}
{{ exporting ? '导出中...' : '导出' }}
</button>
</div>
</div>
@ -35,27 +35,29 @@
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<select v-model="filters.status" class="select" @change="handleSearch">
<option value="">全部状态</option>
<option value="已借出">已借出</option>
<option value="已归还">已归还</option>
<option value="逾期">逾期</option>
</select>
<input
v-model.trim="filters.person_name"
class="input"
type="text"
placeholder="输入花名、姓名或证件号"
@keyup.enter="handleSearch"
/>
<input
v-model.trim="filters.keyword"
class="input"
type="text"
placeholder="资产名称/编码"
@keyup.enter="handleSearch"
/>
<button class="btn-secondary" @click="handleReset">重置</button>
<div class="filter-bar__inputs">
<select v-model="filters.status" class="select" @change="handleSearch">
<option value="">全部状态</option>
<option value="已借出">已借出</option>
<option value="已归还">已归还</option>
<option value="逾期">逾期</option>
</select>
<input
v-model.trim="filters.person_name"
class="input"
type="text"
placeholder="输入花名、姓名或证件号"
@keyup.enter="handleSearch"
/>
<input
v-model.trim="filters.keyword"
class="input"
type="text"
placeholder="资产名称/编码"
@keyup.enter="handleSearch"
/>
<button class="btn-secondary text-xs sm:text-sm" @click="handleReset">重置</button>
</div>
</div>
</section>
@ -179,28 +181,28 @@
</section>
<!-- 状态快照 -->
<div class="space-y-6">
<div class="space-y-4">
<section class="card">
<h3 class="section-title">状态快照</h3>
<div class="mt-4 space-y-3">
<div class="mt-3 space-y-2">
<div class="panel flex items-center justify-between">
<div>
<p class="text-xs text-text-tertiary uppercase">已借出</p>
<p class="text-lg font-semibold text-status-warning mt-1">{{ formatNumber(pageSummary.borrowed) }}</p>
<p class="text-base font-semibold text-status-warning mt-1">{{ formatNumber(pageSummary.borrowed) }}</p>
</div>
<span class="badge badge-warning">待归还</span>
</div>
<div class="panel flex items-center justify-between">
<div>
<p class="text-xs text-text-tertiary uppercase">已归还</p>
<p class="text-lg font-semibold text-status-success mt-1">{{ formatNumber(pageSummary.returned) }}</p>
<p class="text-base font-semibold text-status-success mt-1">{{ formatNumber(pageSummary.returned) }}</p>
</div>
<span class="badge badge-success">已完成</span>
</div>
<div class="panel flex items-center justify-between">
<div>
<p class="text-xs text-text-tertiary uppercase">长期借用</p>
<p class="text-lg font-semibold text-text-primary mt-1">{{ formatNumber(pageSummary.longTerm) }}</p>
<p class="text-base font-semibold text-text-primary mt-1">{{ formatNumber(pageSummary.longTerm) }}</p>
</div>
<span class="badge badge-neutral">在职长期</span>
</div>

View File

@ -10,17 +10,17 @@
<!-- 扫码区域 -->
<div class="scanner-hero">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="page-kicker">Mobile First</p>
<h3 class="text-xl font-semibold text-text-primary">先扫码,再确认出库</h3>
<h3 class="text-lg font-semibold text-text-primary">扫码出库</h3>
</div>
<div class="form-inline-actions">
<button type="button" class="btn-primary w-full sm:w-auto text-sm" @click="showCameraScanner = true">
开始手机扫码
手机扫码
</button>
<button type="button" class="btn-secondary w-full sm:w-auto text-sm" @click="showManualEntry = !showManualEntry">
{{ showManualEntry ? '收起手动输入' : '手动输入 / 扫码枪' }}
{{ showManualEntry ? '收起' : '手动输入' }}
</button>
</div>
</div>
@ -42,22 +42,22 @@
<div v-if="showManualEntry" class="form-panel">
<div class="form-panel__header">
<h4 class="form-panel__title">手动输入 / 扫码枪补录</h4>
<h4 class="form-panel__title">手动输入</h4>
</div>
<div class="field-stack">
<label class="field-label">资产编码</label>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="flex flex-col gap-2 sm:flex-row">
<input
ref="scanInputRef"
v-model="scanCode"
type="text"
class="input text-lg"
placeholder="输入资产编码后按回车或点击读取"
class="input"
placeholder="输入编码后回车"
@keyup.enter="handleScan"
@focus="handleFocus"
/>
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[120px] text-sm" @click="handleScan">
读取资产
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan">
读取
</button>
</div>
</div>
@ -106,38 +106,34 @@
</div>
<!-- 已挂载配件列表 -->
<div v-if="mountedChildren.length > 0" class="mt-4 pt-4 border-t border-border">
<h4 class="text-sm font-medium text-text-primary mb-3">已挂载配件(将随主资产一起借出)</h4>
<div class="space-y-2">
<div v-if="mountedChildren.length > 0" class="mt-3 pt-3 border-t border-border">
<h4 class="text-xs font-medium text-text-primary mb-2">已挂载配件({{ mountedChildren.length }} 件)</h4>
<div class="space-y-1">
<div
v-for="child in mountedChildren"
:key="child.id"
class="panel flex items-center justify-between"
class="flex items-center justify-between text-sm"
>
<div>
<span class="text-sm text-text-primary">{{ child.equipment_name }}</span>
<span class="text-xs text-text-tertiary ml-2 font-mono">
{{ child.equipment_code }} · ¥{{ child.unit_price }}
</span>
</div>
<span class="text-text-primary">{{ child.equipment_name }}</span>
<span class="text-text-tertiary font-mono">{{ child.equipment_code }}</span>
</div>
</div>
<p class="text-sm text-text-tertiary mt-2">
配件总价值:¥{{ mountedChildrenValue.toFixed(2) }}
<p class="text-xs text-text-tertiary mt-2">
配件价值:¥{{ mountedChildrenValue.toFixed(0) }}
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between pt-4 border-t border-border mt-4">
<p class="text-sm text-text-secondary">
请核对资产信息无误后再进行出库操作。
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between pt-3 border-t border-border mt-3">
<p class="text-xs text-text-secondary">
核对无误后再出库
</p>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<button
v-if="!equipmentConfirmed"
class="btn-primary"
class="btn-primary text-sm"
@click="equipmentConfirmed = true"
>
确认资产信息
确认
</button>
<span
v-else
@ -153,8 +149,8 @@
<CameraScannerModal
v-model="showCameraScanner"
title="扫码借出"
description="将资产二维码对准镜头,识别成功后会自动读取资产信息。"
manual-placeholder="请输入资产编码"
description="对准资产二维码"
manual-placeholder="资产编码"
@detected="handleCameraDetected"
/>
@ -208,13 +204,13 @@
当前没有匹配到使用者,请换个关键词再试。
</p>
</div>
<p class="text-sm text-text-tertiary">
未找到需要的人员?前往
<p class="text-xs text-text-tertiary">
没有对应人员?前往
<router-link
:to="{ path: '/settings', query: { tab: 'persons' } }"
class="font-medium text-accent hover:underline"
>
系统设置 &gt; 使用者信息
人员维护
</router-link>
新增。
</p>
@ -260,8 +256,8 @@
class="input"
:disabled="borrowForm.is_long_term"
/>
<p class="text-sm text-text-tertiary">
选择"在职长期"后将不设置具体归还日期。
<p class="text-xs text-text-tertiary">
勾选后不设具体归还日期
</p>
</div>
<div class="field-stack">

View File

@ -10,17 +10,17 @@
<!-- Scan Input Area -->
<div class="scanner-hero">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="page-kicker">Return Flow</p>
<h3 class="text-xl font-semibold text-text-primary">先扫码,再确认归还人</h3>
<h3 class="text-lg font-semibold text-text-primary">扫码归还</h3>
</div>
<div class="form-inline-actions">
<button type="button" class="btn-primary w-full sm:w-auto text-sm" @click="showCameraScanner = true">
开始手机扫码
手机扫码
</button>
<button type="button" class="btn-secondary w-full sm:w-auto text-sm" @click="showManualEntry = !showManualEntry">
{{ showManualEntry ? '收起手动输入' : '手动输入 / 扫码枪' }}
{{ showManualEntry ? '收起' : '手动输入' }}
</button>
</div>
</div>
@ -58,22 +58,22 @@
<div v-if="showManualEntry" class="form-panel">
<div class="form-panel__header">
<h4 class="form-panel__title">手动输入 / 扫码枪补录</h4>
<h4 class="form-panel__title">手动输入</h4>
</div>
<div class="field-stack">
<label class="field-label">资产编码</label>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="flex flex-col gap-2 sm:flex-row">
<input
ref="scanInputRef"
v-model="scanCode"
type="text"
class="input text-lg"
placeholder="输入资产编码后按回车或点击读取"
class="input"
placeholder="输入编码后回车"
@keyup.enter="handleScan"
@focus="handleFocus"
/>
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[120px] text-sm" @click="handleScan">
读取记录
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan">
读取
</button>
</div>
</div>
@ -157,9 +157,9 @@
</div>
<p
v-if="borrowRecordInfo.borrow_records.length > 1"
class="text-sm text-text-tertiary mt-2"
class="mt-2 text-xs text-text-tertiary"
>
提示:该资产有多位使用者同时借出,请选择正确的归还人。
该资产有多位使用者,请选择正确归还人。
</p>
</div>
<div v-if="selectedBorrowRecord" class="panel field-grid">
@ -200,17 +200,17 @@
</div>
<!-- 配件快照对比 -->
<div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-4 pt-4 border-t border-border">
<h4 class="text-sm font-medium text-text-primary mb-3">配件对比</h4>
<div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-3 border-t border-border pt-3">
<h4 class="mb-2 text-sm font-medium text-text-primary">配件对比</h4>
<!-- 借出时配件 -->
<div v-if="childrenSnapshot.length > 0" class="mb-4">
<div v-if="childrenSnapshot.length > 0" class="mb-3">
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p>
<div class="space-y-2">
<div class="space-y-1.5">
<div
v-for="child in childrenSnapshot"
:key="child.id"
class="flex items-center justify-between p-3 rounded-md"
class="flex items-center justify-between rounded-md p-2.5"
:class="isChildMissing(child.id) ? 'bg-status-error/10 border border-status-error/30' : 'bg-bg-elevated border border-border'"
>
<span :class="isChildMissing(child.id) ? 'text-status-error' : 'text-text-primary'" class="text-sm">
@ -229,11 +229,11 @@
<!-- 当前配件 -->
<div v-if="currentChildren.length > 0">
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">当前配件:</p>
<div class="space-y-2">
<div class="space-y-1.5">
<div
v-for="child in currentChildren"
:key="child.id"
class="flex items-center justify-between p-3 rounded-md"
class="flex items-center justify-between rounded-md p-2.5"
:class="isChildNew(child.id) ? 'bg-status-success/10 border border-status-success/30' : 'bg-bg-elevated border border-border'"
>
<span :class="isChildNew(child.id) ? 'text-status-success' : 'text-text-primary'" class="text-sm">
@ -250,12 +250,12 @@
</div>
<!-- 差异提示 -->
<div v-if="hasChildrenDiff" class="mt-4 p-3 bg-status-warning/10 border border-status-warning/30 rounded-md">
<p class="text-sm text-status-warning flex items-center gap-2">
<div v-if="hasChildrenDiff" class="mt-3 rounded-md border border-status-warning/30 bg-status-warning/10 p-2.5">
<p class="flex items-center gap-2 text-sm text-status-warning">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
配件与借出时不一致,请确认配件变更情况
配件有差异,请核对
</p>
</div>
</div>
@ -267,8 +267,8 @@
<CameraScannerModal
v-model="showCameraScanner"
title="扫码归还"
description="将资产二维码对准镜头,识别成功后会自动读取借用记录。"
manual-placeholder="请输入资产编码"
description="对准资产二维码"
manual-placeholder="资产编码"
@detected="handleCameraDetected"
/>

View File

@ -20,7 +20,7 @@
<div>
<p class="page-kicker">Overview</p>
<h2 class="page-title">资产运行总览</h2>
<div class="mt-5 flex flex-wrap gap-3">
<div class="mt-3 flex flex-wrap gap-2">
<router-link to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
<router-link to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
<router-link v-if="isAdmin" to="/equipment" class="btn-secondary text-sm">资产管理</router-link>
@ -47,7 +47,7 @@
</section>
<!-- 统计卡片 -->
<div class="grid gap-3 sm:gap-4 grid-cols-2 lg:grid-cols-4">
<div class="grid gap-2 sm:gap-3 grid-cols-2 lg:grid-cols-4">
<div v-for="card in statCards.slice(0, 4)" :key="card.title" class="card">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
@ -63,7 +63,7 @@
</div>
<!-- 图表区域 -->
<div class="grid gap-6 lg:grid-cols-2">
<div class="grid gap-4 lg:grid-cols-2">
<!-- 资产状态分布 -->
<section class="card">
<div class="list-header">
@ -71,7 +71,7 @@
<span class="badge badge-neutral">实时</span>
</div>
<div ref="statusChartRef" class="h-64"></div>
<div class="mt-4 grid grid-cols-3 gap-3">
<div class="mt-3 grid grid-cols-3 gap-2">
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">可用</p>
<p class="text-lg font-semibold text-status-success mt-1">{{ formatNumber(stats?.available_equipment || 0) }}</p>
@ -108,7 +108,7 @@
</div>
<span class="text-lg font-semibold text-text-primary">{{ formatNumber(item.total_quantity) }}</span>
</div>
<div v-if="topCategories.length === 0" class="text-sm text-text-secondary py-4 text-center">
<div v-if="topCategories.length === 0" class="text-sm text-text-secondary py-3 text-center">
暂无数据
</div>
</div>
@ -203,38 +203,38 @@
<!-- 运营提醒 -->
<section class="card">
<h3 class="section-title mb-5">运营概览</h3>
<div class="space-y-4">
<h3 class="section-title mb-3">运营概览</h3>
<div class="space-y-3">
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">总资产价值</p>
<p class="text-2xl font-semibold text-text-primary mt-1">
<p class="text-xl font-semibold text-text-primary mt-1">
{{ formatCurrencyNoFraction(stats?.total_value || 0) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">可用资产</p>
<p class="text-lg font-semibold text-status-success mt-1">
<p class="text-base font-semibold text-status-success mt-1">
{{ formatNumber(stats?.available_equipment || 0) }}
</p>
<p class="text-xs text-text-secondary mt-1">
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.available_value || 0) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">借出资产</p>
<p class="text-lg font-semibold text-status-warning mt-1">
<p class="text-base font-semibold text-status-warning mt-1">
{{ formatNumber(stats?.borrowed_equipment || 0) }} 件
</p>
<p class="text-xs text-text-secondary mt-1">
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.borrowed_value || 0) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">维护中</p>
<p class="text-lg font-semibold text-status-error mt-1">
<p class="text-base font-semibold text-status-error mt-1">
{{ formatNumber(stats?.maintenance_equipment || 0) }} 件
</p>
<p class="text-xs text-text-secondary mt-1">
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}
</p>
</div>

View File

@ -9,7 +9,7 @@
<div v-if="isEdit" class="flex gap-3">
<button @click="handleDelete" class="btn-danger text-sm">删除资产</button>
<button @click="handlePrintBarcode" class="btn-secondary text-sm">打印标签</button>
<button @click="handlePrintBarcode" class="btn-secondary btn-secondary--highlight text-sm">打印标签</button>
</div>
</section>

View File

@ -5,7 +5,7 @@
<div>
<p class="page-kicker">Asset Control</p>
<h2 class="page-title">资产管理中心</h2>
<div class="mt-5 flex flex-wrap gap-3">
<div class="mt-3 flex flex-wrap gap-2">
<button @click="handleAdd" class="btn-primary text-sm">新增资产</button>
<button @click="showAssetScanner = true" class="btn-secondary text-sm">扫码找资产</button>
</div>
@ -40,7 +40,7 @@
v-model="filters.keyword"
type="text"
class="input w-full pl-9"
placeholder="搜索资产编码 / 名称 / 型号"
placeholder="搜索资产编码 / 名称 / 型号 / 使用人 / 工号"
@input="handleSearch"
/>
</div>
@ -56,14 +56,11 @@
<option value="待报废">待报废</option>
<option value="已报废">已报废</option>
</select>
</div>
<div class="filter-bar__actions">
<button @click="showAdvancedFilters = !showAdvancedFilters" class="btn-secondary text-sm">
{{ showAdvancedFilters || hasAdvancedFilters ? '收起筛选' : '更多筛选' }}
<button @click="showAdvancedFilters = !showAdvancedFilters" class="btn-secondary text-xs sm:text-sm whitespace-nowrap">
{{ showAdvancedFilters || hasAdvancedFilters ? '收起' : '更多' }}
</button>
<div class="relative">
<button @click="showMoreActions = !showMoreActions" class="btn-secondary text-sm">更多操作</button>
<button @click="showMoreActions = !showMoreActions" class="btn-secondary text-xs sm:text-sm whitespace-nowrap">操作</button>
<div
v-if="showMoreActions"
class="absolute right-0 top-full z-20 mt-2 w-44 rounded-xl border border-border bg-bg-secondary p-2 shadow-[0_18px_44px_rgba(0,0,0,0.36)]"
@ -84,7 +81,7 @@
</div>
<!-- 高级筛选 -->
<div v-if="showAdvancedFilters || hasAdvancedFilters" class="mt-4 grid gap-3 border-t border-border pt-4 md:grid-cols-2 lg:grid-cols-4">
<div v-if="showAdvancedFilters || hasAdvancedFilters" class="mt-3 grid gap-2 border-t border-border pt-3 sm:grid-cols-2 lg:grid-cols-4">
<select v-model="filters.mountStatus" class="select" @change="handleSearch">
<option value="">挂载状态</option>
<option value="unmounted">未挂载</option>
@ -102,13 +99,13 @@
</div>
<!-- 批量操作 -->
<div v-if="selectedIds.length > 0" class="mt-4 flex flex-col gap-3 border-t border-border pt-4 lg:flex-row lg:items-center lg:justify-between">
<div class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项,可直接执行批量处理。</div>
<div v-if="selectedIds.length > 0" class="mt-3 flex flex-col gap-2 border-t border-border pt-3 lg:flex-row lg:items-center lg:justify-between">
<div class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项</div>
<div class="flex flex-wrap gap-2">
<button @click="showBatchBorrowModal = true" class="btn-secondary text-sm">批量借出</button>
<button @click="showBatchReturnModal = true" class="btn-secondary text-sm">批量归还</button>
<button @click="showBatchTransferModal = true" class="btn-secondary text-sm">批量转移</button>
<button @click="selectedIds = []" class="btn-secondary text-sm text-status-error">取消选择</button>
<button @click="selectedIds = []" class="btn-secondary text-sm text-status-error">取消</button>
</div>
</div>
</section>
@ -139,6 +136,10 @@
<p class="mobile-card__label">资产类型</p>
<p class="mobile-card__value">{{ equipment.equipment_type }}</p>
</div>
<div>
<p class="mobile-card__label">使用人</p>
<p class="mobile-card__value">{{ getCurrentBorrowerLabel(equipment) }}</p>
</div>
<div>
<p class="mobile-card__label">库存</p>
<p class="mobile-card__value">{{ equipment.remaining_quantity }} / {{ equipment.total_quantity }}</p>
@ -158,25 +159,29 @@
{{ expandedIds.has(equipment.id) ? '收起' : `配件(${equipment.children?.length || 0})` }}
</button>
<button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button>
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary text-xs">标签</button>
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button>
<button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button>
</div>
<!-- 子资产展开 -->
<div v-if="expandedIds.has(equipment.id) && equipment.children?.length" class="mt-4 space-y-2 border-t border-border pt-4">
<div v-for="child in equipment.children" :key="`mobile-child-${child.id}`" class="rounded-lg border border-border bg-bg-elevated/50 px-4 py-3">
<div v-if="expandedIds.has(equipment.id) && equipment.children?.length" class="mt-3 space-y-2 border-t border-border pt-3">
<div v-for="child in equipment.children" :key="`mobile-child-${child.id}`" class="rounded-lg border border-border bg-bg-elevated/50 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ child.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ child.equipment_code }}</p>
<p class="text-xs font-mono text-text-tertiary">{{ child.equipment_code }}</p>
</div>
<span class="badge badge-neutral">配件</span>
</div>
<div class="mt-3 mobile-card__meta">
<div class="mt-2 mobile-card__meta">
<div>
<p class="mobile-card__label">类型</p>
<p class="mobile-card__value">{{ child.equipment_type }}</p>
</div>
<div>
<p class="mobile-card__label">使用人</p>
<p class="mobile-card__value">{{ getCurrentBorrowerLabel(child) }}</p>
</div>
<div>
<p class="mobile-card__label">单价</p>
<p class="mobile-card__value">{{ formatCurrencyNoFraction(Number(child.unit_price || 0)) }}</p>
@ -235,8 +240,10 @@
</td>
<td class="align-top">
<div class="space-y-2">
<p class="text-sm text-text-secondary">{{ equipment.department?.name || '未分配' }}</p>
<p class="text-xs text-text-tertiary">{{ equipment.storageLocation?.name || '未设置地址' }}</p>
<p class="text-sm font-medium text-text-primary">{{ getCurrentBorrowerLabel(equipment) }}</p>
<p class="text-xs text-text-tertiary">{{ getCurrentBorrowerMeta(equipment) }}</p>
<p class="text-xs text-text-tertiary">部门:{{ equipment.department?.name || '未分配' }}</p>
<p class="text-xs text-text-tertiary">地址:{{ equipment.storageLocation?.name || '未设置地址' }}</p>
</div>
</td>
<td class="align-top">
@ -251,7 +258,7 @@
<td class="align-top">
<div class="table-action-group justify-end pt-1">
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button>
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action">标签</button>
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
<button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
</div>
</td>
@ -259,13 +266,13 @@
<!-- 子资产展开行 -->
<tr v-if="expandedIds.has(equipment.id) && equipment.children?.length" class="bg-bg-tertiary/40">
<td colspan="5" class="px-4 py-4">
<div class="rounded-xl border border-border bg-bg-elevated/40 p-4">
<td colspan="5" class="px-3 py-3">
<div class="rounded-lg border border-border bg-bg-elevated/40 p-3">
<div class="flex items-center justify-between gap-4">
<h4 class="section-title">已挂载配件</h4>
<span class="badge badge-neutral">{{ equipment.children.length }} 项</span>
</div>
<div class="mt-4 grid gap-3 xl:grid-cols-2">
<div class="mt-3 grid gap-2 xl:grid-cols-2">
<article v-for="child in equipment.children" :key="'child-' + child.id" class="panel">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
@ -276,17 +283,18 @@
<span class="badge badge-neutral">子资产</span>
<span v-if="child.status" class="badge" :class="getStatusBadgeClass(child.status)">{{ child.status }}</span>
</div>
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-tertiary">
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-tertiary">
<span class="font-mono">{{ child.equipment_code }}</span>
<span>{{ child.equipment_type || '未设置类型' }}</span>
<span>使用人:{{ getCurrentBorrowerLabel(child) }}</span>
</div>
</div>
</div>
<p class="text-sm font-semibold text-text-primary">{{ formatCurrencyNoFraction(Number(child.unit_price || 0)) }}</p>
</div>
<div class="mt-4 table-action-group justify-end">
<div class="mt-3 table-action-group justify-end">
<button @click="handleEdit(child.id)" class="table-action table-action--primary">编辑</button>
<button @click="handlePrintBarcode(child.id)" class="table-action">标签</button>
<button @click="handlePrintBarcode(child.id)" class="table-action table-action--highlight">标签</button>
<button @click="handleDelete(child.id)" class="table-action table-action--danger">删除</button>
</div>
</article>
@ -664,6 +672,35 @@ const submitBatchTransfer = async () => {
const isParentAsset = (e: Equipment) => e.children && e.children.length > 0
const hasChildren = (e: Equipment) => e.children && e.children.length > 0
const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: Equipment['current_borrow'] | null }) => {
const currentBorrow = equipment.current_borrow
if (!currentBorrow) {
return equipment.status === '已借出' ? '已借出,待核对' : '未借出'
}
const nickname = currentBorrow.person?.nickname || currentBorrow.person_nickname
const realName = currentBorrow.person?.real_name || currentBorrow.person_name
if (nickname && realName && nickname !== realName) {
return `${nickname}(${realName})`
}
return nickname || realName || '待核对'
}
const getCurrentBorrowerMeta = (equipment: { current_borrow?: Equipment['current_borrow'] | null }) => {
const currentBorrow = equipment.current_borrow
if (!currentBorrow?.borrow_time) {
return '暂无借用记录'
}
const borrowDate = currentBorrow.borrow_time.includes('T')
? currentBorrow.borrow_time.split('T')[0]
: currentBorrow.borrow_time
return `借出时间:${borrowDate}`
}
const toggleExpand = (id: number) => {
if (expandedIds.value.has(id)) expandedIds.value.delete(id)
else expandedIds.value.add(id)

View File

@ -0,0 +1,329 @@
<template>
<div class="space-y-6">
<section class="page-header">
<div>
<p class="page-kicker">Scope Builder</p>
<h2 class="page-title">新建盘点任务</h2>
<div class="mt-3 flex flex-wrap gap-2">
<router-link to="/inventory" class="btn-secondary text-sm">返回</router-link>
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
{{ previewLoading ? '预览中...' : '预览' }}
</button>
<button type="button" class="btn-primary text-sm" :disabled="!canCreate" @click="handleSubmit">
{{ creating ? '创建中...' : '创建' }}
</button>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">当前范围</p>
<p class="page-meta__value text-base">{{ currentScopeLabel }}</p>
<p class="page-meta__subvalue">部门/类型留空表示全量</p>
</div>
<div class="page-meta">
<p class="page-meta__label">预估参与人</p>
<p class="page-meta__value">{{ formatNumber(preview?.participants_count || 0) }}</p>
<p class="page-meta__subvalue">来自借用关系快照</p>
</div>
<div class="page-meta">
<p class="page-meta__label">预估资产数</p>
<p class="page-meta__value">{{ formatNumber(preview?.items_count || 0) }}</p>
<p class="page-meta__subvalue">无人归属 {{ formatNumber(preview?.unassigned_count || 0) }} 件</p>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">任务信息</h3>
<span v-if="previewDirty" class="badge badge-warning">范围已变更,请重新预览</span>
</div>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">任务标题 *</label>
<input
v-model.trim="form.title"
type="text"
class="input"
placeholder="例如:2026 年 3 月办公区资产盘点"
/>
</div>
<div class="field-stack">
<label class="field-label">业务部门</label>
<select v-model="form.department_id" class="select">
<option value="">全部业务部门</option>
<option v-for="department in departments" :key="department.id" :value="String(department.id)">
{{ department.name }}
</option>
</select>
</div>
<div class="field-stack">
<label class="field-label">资产类型</label>
<select v-model="form.asset_type_id" class="select">
<option value="">全部资产类型</option>
<option v-for="assetType in assetTypes" :key="assetType.id" :value="String(assetType.id)">
{{ assetType.name }}
</option>
</select>
</div>
<div class="field-stack lg:col-span-2">
<label class="field-label">任务说明</label>
<textarea
v-model.trim="form.description"
class="input"
rows="3"
placeholder="可选,给盘点负责人留一句简短说明"
></textarea>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">范围预览</h3>
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
{{ previewLoading ? '刷新中...' : '刷新' }}
</button>
</div>
<div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div>
<div v-else-if="preview" class="space-y-6">
<div class="grid gap-4 sm:grid-cols-3">
<div class="panel">
<p class="mobile-card__label">参与人</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.participants_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">自动生成名单</p>
</div>
<div class="panel">
<p class="mobile-card__label">待盘点资产</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.items_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">按当前快照生成</p>
</div>
<div class="panel">
<p class="mobile-card__label">无人归属</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">创建后尽快分配</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<section class="space-y-3">
<h4 class="section-title">按部门拆分</h4>
<div v-if="preview.department_breakdown.length === 0" class="empty-state">当前范围没有匹配到部门分布。</div>
<div v-else class="space-y-3">
<div
v-for="department in preview.department_breakdown.slice(0, 8)"
:key="department.department_name"
class="panel"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ department.department_name }}</p>
<p class="mt-1 text-xs text-text-secondary">
已分配 {{ formatNumber(department.assigned) }} 件 / 待分配 {{ formatNumber(department.unassigned) }} 件
</p>
</div>
<span class="badge badge-neutral">{{ formatNumber(department.total) }} 件</span>
</div>
</div>
</div>
</section>
<section class="space-y-3">
<h4 class="section-title">按资产类型拆分</h4>
<div v-if="preview.asset_type_breakdown.length === 0" class="empty-state">当前范围没有匹配到资产类型分布。</div>
<div v-else class="space-y-3">
<div
v-for="assetType in preview.asset_type_breakdown.slice(0, 8)"
:key="assetType.asset_type_name"
class="panel flex items-center justify-between gap-3"
>
<div>
<p class="text-sm font-medium text-text-primary">{{ assetType.asset_type_name }}</p>
<p class="mt-1 text-xs text-text-secondary">已纳入本次盘点范围</p>
</div>
<span class="badge badge-neutral">{{ formatNumber(assetType.total) }} 件</span>
</div>
</div>
</section>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<section class="space-y-3">
<h4 class="section-title">参与人样例</h4>
<div v-if="preview.participants_sample.length === 0" class="empty-state">当前范围没有生成参与人。</div>
<div v-else class="space-y-3">
<div
v-for="participant in preview.participants_sample"
:key="participant.person_id"
class="panel"
>
<p class="text-sm font-medium text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ participant.department_name || '未归属部门' }}
</p>
</div>
</div>
</section>
<section class="space-y-3">
<h4 class="section-title">资产样例</h4>
<div v-if="preview.items_sample.length === 0" class="empty-state">当前范围没有资产。</div>
<div v-else class="space-y-3">
<div
v-for="item in preview.items_sample"
:key="item.equipment_id"
class="panel"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
<p class="mt-2 text-xs text-text-secondary">
{{ item.snapshot_person_nickname || '待分配人员' }}
<span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span>
</p>
</div>
<span class="badge" :class="item.is_unassigned ? 'badge-warning' : 'badge-neutral'">
{{ item.is_unassigned ? '待分配' : '已归属' }}
</span>
</div>
</div>
</div>
</section>
</div>
</div>
<div v-else class="empty-state">还没有生成预览,点“预览”查看本次覆盖范围。</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getAssetTypeOptions } from '@/api/assetType'
import { getDepartmentOptions } from '@/api/department'
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
import type { AssetType, Department, InventoryTaskPreview } from '@/types'
import { formatNumber } from '@/utils/format'
const router = useRouter()
const departments = ref<Department[]>([])
const assetTypes = ref<AssetType[]>([])
const preview = ref<InventoryTaskPreview | null>(null)
const previewLoading = ref(false)
const creating = ref(false)
const previewDirty = ref(true)
const form = reactive({
title: '',
description: '',
department_id: '',
asset_type_id: ''
})
const selectedDepartment = computed(
() => departments.value.find((department) => String(department.id) === form.department_id) || null
)
const selectedAssetType = computed(
() => assetTypes.value.find((assetType) => String(assetType.id) === form.asset_type_id) || null
)
const currentScopeLabel = computed(() => {
const segments = []
if (selectedDepartment.value) {
segments.push(`部门:${selectedDepartment.value.name}`)
}
if (selectedAssetType.value) {
segments.push(`资产类型:${selectedAssetType.value.name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
})
const canCreate = computed(() => form.title.length > 0 && !creating.value)
const buildPreviewPayload = () => ({
department_id: form.department_id ? Number(form.department_id) : null,
asset_type_id: form.asset_type_id ? Number(form.asset_type_id) : null
})
const loadOptions = async () => {
try {
const [departmentResponse, assetTypeResponse] = await Promise.all([
getDepartmentOptions(),
getAssetTypeOptions()
])
departments.value = departmentResponse.data.filter((department) => department.status === 'active')
assetTypes.value = assetTypeResponse.data.filter((assetType) => assetType.status === 'active')
} catch (error: any) {
alert(error?.message || '加载盘点范围选项失败')
}
}
const handlePreview = async () => {
previewLoading.value = true
try {
const response = await previewInventoryTask(buildPreviewPayload())
preview.value = response.data
previewDirty.value = false
} catch (error: any) {
alert(error?.message || '预览盘点范围失败')
} finally {
previewLoading.value = false
}
}
const handleSubmit = async () => {
if (!form.title.trim()) {
alert('请先填写任务标题')
return
}
if (previewDirty.value || !preview.value) {
await handlePreview()
}
if (!preview.value) {
return
}
if (preview.value.items_count === 0) {
const shouldContinue = window.confirm('当前范围没有匹配到资产,仍然要创建空盘点任务吗?')
if (!shouldContinue) {
return
}
}
creating.value = true
try {
const response = await createInventoryTask({
title: form.title.trim(),
description: form.description.trim() || undefined,
...buildPreviewPayload()
})
alert('盘点任务已创建')
router.push(`/inventory/${response.data.id}`)
} catch (error: any) {
alert(error?.message || '创建盘点任务失败')
} finally {
creating.value = false
}
}
watch(
() => [form.department_id, form.asset_type_id],
() => {
previewDirty.value = true
}
)
onMounted(async () => {
await loadOptions()
handlePreview()
})
</script>

View File

@ -0,0 +1,736 @@
<template>
<div class="space-y-6">
<section v-if="loading && !task" class="card">
<div class="empty-state">正在加载盘点任务详情...</div>
</section>
<template v-else-if="task">
<section class="page-header">
<div>
<p class="page-kicker">Inventory Detail</p>
<h2 class="page-title">{{ task.title }}</h2>
<p class="mt-2 text-sm text-text-secondary">{{ task.description || '未填写任务说明' }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<router-link to="/inventory" class="btn-secondary text-sm">返回</router-link>
<button type="button" class="btn-secondary text-sm" :disabled="loading" @click="loadTask">刷新</button>
<button type="button" class="btn-secondary text-sm" @click="copyPublicLink">复制链接</button>
<button type="button" class="btn-secondary text-sm" @click="openPublicLink">手机页</button>
<button
v-if="task.status === 'active'"
type="button"
class="btn-secondary text-sm !text-status-error"
:disabled="closing"
@click="handleCloseTask"
>
{{ closing ? '关闭中...' : '关闭任务' }}
</button>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">任务状态</p>
<p class="page-meta__value">{{ getTaskStatusText(task.status) }}</p>
<p class="page-meta__subvalue">
{{ task.closed_at ? `关闭于 ${formatDateTime(task.closed_at)}` : `创建于 ${formatDateTime(task.created_at)}` }}
</p>
</div>
<div class="page-meta">
<p class="page-meta__label">总体进度</p>
<p class="page-meta__value">{{ taskProgressText }}</p>
<p class="page-meta__subvalue">异常 {{ formatNumber(task.exception_count) }} 件</p>
</div>
<div class="page-meta">
<p class="page-meta__label">参与人</p>
<p class="page-meta__value">{{ formatNumber(task.participants_count) }}</p>
<p class="page-meta__subvalue">待确认 {{ formatNumber(pendingParticipantCount) }} 名</p>
</div>
<div class="page-meta">
<p class="page-meta__label">待分配资产</p>
<p class="page-meta__value">{{ formatNumber(unassignedItems.length) }}</p>
<p class="page-meta__subvalue">手机链接随任务自动生成</p>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">任务概览</h3>
<span class="badge" :class="task.status === 'closed' ? 'badge-neutral' : 'badge-success'">
{{ getTaskStatusText(task.status) }}
</span>
</div>
<div class="field-grid">
<div class="field-stack">
<p class="field-label">盘点范围</p>
<p class="text-sm text-text-primary">{{ scopeLabel }}</p>
</div>
<div class="field-stack">
<p class="field-label">创建人</p>
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
</div>
<div class="field-stack">
<p class="field-label">手机盘点地址</p>
<div class="flex flex-col gap-2 sm:flex-row">
<input :value="publicLink" type="text" class="input font-mono text-xs" readonly />
<button type="button" class="btn-secondary text-sm sm:w-auto" @click="copyPublicLink">复制</button>
</div>
</div>
<div class="field-stack">
<p class="field-label">范围快照时间</p>
<p class="text-sm text-text-primary">
{{ task.scope_snapshot?.generated_at ? formatDateTime(task.scope_snapshot.generated_at) : '未记录' }}
</p>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<section class="space-y-3">
<h4 class="section-title">部门进度</h4>
<div v-if="task.department_breakdown.length === 0" class="empty-state">暂无部门进度数据。</div>
<div v-else class="space-y-3">
<div
v-for="department in task.department_breakdown"
:key="department.department_name"
class="panel"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ department.department_name }}</p>
<p class="mt-1 text-xs text-text-secondary">
已确认 {{ formatNumber(department.checked) }} / 异常 {{ formatNumber(department.exception) }} / 待盘点 {{ formatNumber(department.pending) }}
</p>
</div>
<span class="badge badge-neutral">{{ formatNumber(department.total) }} 件</span>
</div>
</div>
</div>
</section>
<section class="space-y-3">
<h4 class="section-title">资产类型进度</h4>
<div v-if="task.asset_type_breakdown.length === 0" class="empty-state">暂无资产类型进度数据。</div>
<div v-else class="space-y-3">
<div
v-for="assetType in task.asset_type_breakdown"
:key="assetType.asset_type_name"
class="panel"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ assetType.asset_type_name }}</p>
<p class="mt-1 text-xs text-text-secondary">
已确认 {{ formatNumber(assetType.checked) }} / 异常 {{ formatNumber(assetType.exception) }} / 待盘点 {{ formatNumber(assetType.pending) }}
</p>
</div>
<span class="badge badge-neutral">{{ formatNumber(assetType.total) }} 件</span>
</div>
</div>
</div>
</section>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">参与人进度</h3>
<span class="badge badge-neutral">共 {{ formatNumber(participantRows.length) }} 名</span>
</div>
<div class="mobile-card-list md:hidden">
<article
v-for="participant in participantRows"
:key="participant.id"
class="mobile-card"
>
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<p class="mt-1 text-xs text-text-tertiary">{{ participant.department_name || '未归属部门' }}</p>
</div>
<span class="badge" :class="getParticipantStatusClass(participant.status)">
{{ getParticipantStatusText(participant.status) }}
</span>
</div>
<div class="mobile-card__meta mt-4">
<div>
<p class="mobile-card__label">资产数</p>
<p class="mobile-card__value">{{ formatNumber(participant.total) }}</p>
</div>
<div>
<p class="mobile-card__label">已确认 / 异常</p>
<p class="mobile-card__value">{{ formatNumber(participant.checked) }} / {{ formatNumber(participant.exception) }}</p>
</div>
<div>
<p class="mobile-card__label">最近动作</p>
<p class="mobile-card__value">{{ formatDateTime(participant.last_action_at || participant.confirmed_at || participant.created_at) }}</p>
</div>
<div>
<p class="mobile-card__label">待盘点</p>
<p class="mobile-card__value">{{ formatNumber(participant.pending) }}</p>
</div>
</div>
</article>
<p v-if="participantRows.length === 0" class="py-8 text-center text-sm text-text-tertiary">
当前任务还没有参与人。
</p>
</div>
<div class="hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>参与人</th>
<th>部门</th>
<th>状态</th>
<th>负责资产</th>
<th>已确认 / 异常</th>
<th>最近动作</th>
</tr>
</thead>
<tbody>
<tr v-for="participant in participantRows" :key="participant.id">
<td>
<p class="text-text-primary">{{ participant.person_nickname }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ participant.person_name }}</p>
</td>
<td class="text-sm text-text-primary">{{ participant.department_name || '-' }}</td>
<td>
<span class="badge" :class="getParticipantStatusClass(participant.status)">
{{ getParticipantStatusText(participant.status) }}
</span>
</td>
<td class="text-sm text-text-primary">{{ formatNumber(participant.total) }} 件</td>
<td class="text-sm text-text-secondary">
{{ formatNumber(participant.checked) }} / {{ formatNumber(participant.exception) }}
<span class="block text-xs text-text-tertiary">待盘点 {{ formatNumber(participant.pending) }}</span>
</td>
<td class="text-sm text-text-secondary">
{{ formatDateTime(participant.last_action_at || participant.confirmed_at || participant.created_at) }}
</td>
</tr>
<tr v-if="participantRows.length === 0">
<td colspan="6" class="py-8 text-center text-text-secondary">当前任务还没有参与人。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">盘点资产明细</h3>
<span class="badge badge-neutral">共 {{ formatNumber(filteredItems.length) }} 件</span>
</div>
<div class="filter-bar">
<div class="filter-bar__inputs">
<select v-model="itemFilter" class="select">
<option value="all">全部状态</option>
<option value="pending">待盘点</option>
<option value="checked">已确认</option>
<option value="exception">异常</option>
</select>
<select v-model="participantFilter" class="select">
<option value="all">全部参与人</option>
<option v-for="participant in participantRows" :key="participant.id" :value="String(participant.id)">
{{ participant.person_nickname }} · {{ participant.person_name }}
</option>
</select>
<input
v-model.trim="itemKeyword"
type="text"
class="input"
placeholder="搜索资产编码、名称、使用人"
/>
</div>
</div>
<div class="mt-4 mobile-card-list md:hidden">
<article
v-for="item in filteredItems"
:key="item.id"
class="mobile-card"
>
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
</div>
<span class="badge" :class="getItemStatusClass(item.check_status)">
{{ getItemStatusText(item.check_status) }}
</span>
</div>
<div class="mobile-card__meta mt-4">
<div>
<p class="mobile-card__label">责任人</p>
<p class="mobile-card__value">
{{ item.snapshot_person_nickname || '待分配' }}
<span v-if="item.snapshot_person_name" class="block text-xs text-text-secondary">
{{ item.snapshot_person_name }}
</span>
</p>
</div>
<div>
<p class="mobile-card__label">部门</p>
<p class="mobile-card__value">{{ item.snapshot_department_name || '未归属' }}</p>
</div>
<div>
<p class="mobile-card__label">资产类型</p>
<p class="mobile-card__value">{{ item.asset_type_name || item.equipment_type }}</p>
</div>
<div>
<p class="mobile-card__label">盘点时间</p>
<p class="mobile-card__value">{{ item.checked_at ? formatDateTime(item.checked_at) : '-' }}</p>
</div>
</div>
<div v-if="item.exception_type || item.exception_note" class="mt-4 rounded-xl border border-status-warning/30 bg-status-warning/10 px-3 py-3 text-sm text-text-secondary">
<p class="font-medium text-status-warning">{{ getExceptionText(item.exception_type) }}</p>
<p v-if="item.exception_note" class="mt-1 text-text-secondary">{{ item.exception_note }}</p>
</div>
</article>
<p v-if="filteredItems.length === 0" class="py-8 text-center text-sm text-text-tertiary">
当前筛选条件下没有资产。
</p>
</div>
<div class="mt-4 hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>资产</th>
<th>责任人</th>
<th>部门</th>
<th>状态</th>
<th>异常说明</th>
<th>盘点时间</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item.id">
<td>
<p class="text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ item.asset_type_name || item.equipment_type }}</p>
</td>
<td>
<p class="text-text-primary">{{ item.snapshot_person_nickname || '待分配' }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ item.snapshot_person_name || '-' }}</p>
</td>
<td class="text-sm text-text-primary">{{ item.snapshot_department_name || '-' }}</td>
<td>
<span class="badge" :class="getItemStatusClass(item.check_status)">
{{ getItemStatusText(item.check_status) }}
</span>
</td>
<td class="text-sm text-text-secondary">
{{ getExceptionText(item.exception_type) }}
<span v-if="item.exception_note" class="mt-1 block text-xs text-text-tertiary">{{ item.exception_note }}</span>
</td>
<td class="text-sm text-text-secondary">
{{ item.checked_at ? formatDateTime(item.checked_at) : '-' }}
</td>
</tr>
<tr v-if="filteredItems.length === 0">
<td colspan="6" class="py-8 text-center text-text-secondary">当前筛选条件下没有资产。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section v-if="unassignedItems.length > 0" class="card">
<div class="list-header">
<h3 class="section-title">待分配资产</h3>
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待处理</span>
</div>
<div class="space-y-3">
<div
v-for="item in unassignedItems"
:key="item.id"
class="panel"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
<p class="mt-2 text-xs text-text-secondary">
{{ item.snapshot_department_name || '未归属部门' }} · {{ item.asset_type_name || item.equipment_type }}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-[minmax(0,280px)_auto] sm:items-end">
<select
v-model="assignPersonIdMap[item.id]"
class="select"
:disabled="task.status === 'closed'"
>
<option value="">请选择责任人</option>
<option v-for="person in personOptions" :key="person.id" :value="String(person.id)">
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }}
</option>
</select>
<button
type="button"
class="btn-primary text-sm"
:disabled="task.status === 'closed' || assigningItemId === item.id || !assignPersonIdMap[item.id]"
@click="assignItem(item.id)"
>
{{ assigningItemId === item.id ? '分配中...' : '确认分配' }}
</button>
</div>
</div>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">最近动作</h3>
<span class="badge badge-neutral">最近 {{ formatNumber(task.recent_actions.length) }} 条</span>
</div>
<div v-if="task.recent_actions.length === 0" class="empty-state">暂时还没有动作记录。</div>
<div v-else class="space-y-3">
<div
v-for="action in task.recent_actions"
:key="action.id"
class="panel"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-sm font-medium text-text-primary">{{ resolveActionSummary(action) }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ resolveActionDetail(action) }}</p>
</div>
<p class="text-xs text-text-tertiary">{{ formatDateTime(action.created_at) }}</p>
</div>
</div>
</div>
</section>
</template>
<section v-else class="card">
<div class="empty-state">没有找到这个盘点任务。</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getPersonOptions } from '@/api/person'
import {
assignInventoryTaskUnassignedItem,
closeInventoryTask,
getInventoryTaskDetail,
getInventoryTaskUnassignedItems
} from '@/api/inventory'
import type {
InventoryTaskAction,
InventoryTaskDetail,
InventoryTaskItem,
InventoryTaskParticipant,
Person
} from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
interface ParticipantProgressRow extends InventoryTaskParticipant {
total: number
pending: number
checked: number
exception: number
}
const route = useRoute()
const router = useRouter()
const task = ref<InventoryTaskDetail | null>(null)
const unassignedItems = ref<InventoryTaskItem[]>([])
const personOptions = ref<Person[]>([])
const loading = ref(false)
const closing = ref(false)
const assigningItemId = ref<number | null>(null)
const itemFilter = ref<ItemFilter>('all')
const participantFilter = ref('all')
const itemKeyword = ref('')
const assignPersonIdMap = reactive<Record<number, string>>({})
const taskId = computed(() => Number(route.params.id))
const publicLink = computed(() =>
task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : ''
)
const scopeLabel = computed(() => {
if (!task.value) return '-'
const segments = []
if (task.value.scope_department_name) {
segments.push(`部门:${task.value.scope_department_name}`)
}
if (task.value.scope_asset_type_name) {
segments.push(`资产类型:${task.value.scope_asset_type_name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
})
const taskProgressText = computed(() => {
if (!task.value || task.value.items_count === 0) return '暂无资产'
return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}`
})
const pendingParticipantCount = computed(
() => participantRows.value.filter((participant) => participant.status === 'pending').length
)
const participantRows = computed<ParticipantProgressRow[]>(() => {
const statsMap = new Map<number, { total: number; pending: number; checked: number; exception: number }>()
for (const item of task.value?.items || []) {
if (!item.participant_id) continue
const current = statsMap.get(item.participant_id) || {
total: 0,
pending: 0,
checked: 0,
exception: 0
}
current.total += 1
if (item.check_status === 'checked') {
current.checked += 1
} else if (item.check_status === 'exception') {
current.exception += 1
} else {
current.pending += 1
}
statsMap.set(item.participant_id, current)
}
return (task.value?.participants || []).map((participant) => ({
...participant,
...(statsMap.get(participant.id) || {
total: 0,
pending: 0,
checked: 0,
exception: 0
})
}))
})
const filteredItems = computed(() => {
const keyword = itemKeyword.value.trim().toLowerCase()
return (task.value?.items || []).filter((item) => {
const matchesFilter = itemFilter.value === 'all' || item.check_status === itemFilter.value
const matchesParticipant =
participantFilter.value === 'all' || String(item.participant_id || '') === participantFilter.value
const matchesKeyword =
!keyword ||
item.equipment_code.toLowerCase().includes(keyword) ||
item.equipment_name.toLowerCase().includes(keyword) ||
(item.snapshot_person_name || '').toLowerCase().includes(keyword) ||
(item.snapshot_person_nickname || '').toLowerCase().includes(keyword)
return matchesFilter && matchesParticipant && matchesKeyword
})
})
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中')
const getParticipantStatusText = (status: string) => {
const mapping: Record<string, string> = {
pending: '待确认',
in_progress: '盘点中',
completed: '已完成'
}
return mapping[status] || status
}
const getParticipantStatusClass = (status: string) => {
const mapping: Record<string, string> = {
pending: 'badge-warning',
in_progress: 'badge-neutral',
completed: 'badge-success'
}
return mapping[status] || 'badge-neutral'
}
const getItemStatusText = (status: string) => {
const mapping: Record<string, string> = {
pending: '待盘点',
checked: '已确认',
exception: '异常'
}
return mapping[status] || status
}
const getItemStatusClass = (status: string) => {
const mapping: Record<string, string> = {
pending: 'badge-warning',
checked: 'badge-success',
exception: 'badge-error'
}
return mapping[status] || 'badge-neutral'
}
const getExceptionText = (exceptionType?: string | null) => {
const mapping: Record<string, string> = {
not_found: '资产未找到',
not_with_me: '资产不在本人处'
}
return exceptionType ? mapping[exceptionType] || exceptionType : '无'
}
const parseActionPayload = (payload?: string | null) => {
if (!payload) return null
try {
return JSON.parse(payload) as Record<string, any>
} catch {
return null
}
}
const resolveActionSummary = (action: InventoryTaskAction) => {
const mapping: Record<string, string> = {
task_created: '创建了盘点任务',
task_closed: '关闭了盘点任务',
participant_confirmed: '盘点人完成身份确认',
item_checked: '确认了一件资产',
item_exception_reported: '上报了一件异常资产',
item_reassigned: '重新分配了一件无人归属资产'
}
return mapping[action.action_type] || action.action_type
}
const resolveActionDetail = (action: InventoryTaskAction) => {
const payload = parseActionPayload(action.action_payload)
switch (action.action_type) {
case 'task_created':
return payload
? `参与人 ${formatNumber(payload.participants_count || 0)} 名,资产 ${formatNumber(payload.items_count || 0)} 件,待分配 ${formatNumber(payload.unassigned_count || 0)} 件`
: '任务已初始化完成。'
case 'item_checked':
return payload?.equipment_code ? `资产编码:${payload.equipment_code}` : '资产已被确认。'
case 'item_exception_reported':
return payload
? `${getExceptionText(payload.exception_type)}${payload.exception_note ? `,备注:${payload.exception_note}` : ''}`
: '已记录异常。'
case 'item_reassigned':
return payload?.target_person_name ? `分配给:${payload.target_person_name}` : '已完成重新分配。'
default:
return '详情已记录到盘点日志。'
}
}
const loadPersonOptions = async () => {
try {
const response = await getPersonOptions()
personOptions.value = response.data
.filter((person) => person.status === 'active')
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
} catch (error) {
console.error('获取人员列表失败', error)
}
}
const loadTask = async () => {
if (!Number.isInteger(taskId.value) || taskId.value <= 0) {
router.push('/inventory')
return
}
loading.value = true
try {
const detailResponse = await getInventoryTaskDetail(taskId.value)
task.value = detailResponse.data
if (detailResponse.data.unassigned_count > 0) {
const unassignedResponse = await getInventoryTaskUnassignedItems(taskId.value)
unassignedItems.value = unassignedResponse.data
if (personOptions.value.length === 0) {
loadPersonOptions()
}
} else {
unassignedItems.value = []
}
} catch (error: any) {
alert(error?.message || '获取盘点任务详情失败')
router.push('/inventory')
} finally {
loading.value = false
}
}
const copyPublicLink = async () => {
if (!publicLink.value) return
try {
await navigator.clipboard.writeText(publicLink.value)
alert('手机盘点链接已复制')
} catch {
window.prompt('请复制这个手机盘点链接', publicLink.value)
}
}
const openPublicLink = () => {
if (!publicLink.value) return
window.open(publicLink.value, '_blank', 'noopener,noreferrer')
}
const handleCloseTask = async () => {
if (!task.value) return
const shouldClose = window.confirm('关闭后手机端将不能继续提交盘点结果,确定关闭吗?')
if (!shouldClose) return
closing.value = true
try {
await closeInventoryTask(task.value.id)
alert('盘点任务已关闭')
loadTask()
} catch (error: any) {
alert(error?.message || '关闭盘点任务失败')
} finally {
closing.value = false
}
}
const assignItem = async (itemId: number) => {
if (!task.value) return
const personId = Number(assignPersonIdMap[itemId])
if (!Number.isInteger(personId) || personId <= 0) {
alert('请先选择责任人')
return
}
assigningItemId.value = itemId
try {
await assignInventoryTaskUnassignedItem(task.value.id, itemId, personId)
alert('资产责任人已更新')
loadTask()
} catch (error: any) {
alert(error?.message || '分配责任人失败')
} finally {
assigningItemId.value = null
}
}
watch(
() => route.params.id,
() => {
loadTask()
}
)
onMounted(() => {
loadTask()
})
</script>

View File

@ -0,0 +1,286 @@
<template>
<div class="space-y-6">
<section class="page-header">
<div>
<p class="page-kicker">Inventory Tasks</p>
<h2 class="page-title">资产盘点</h2>
<div class="mt-3 flex flex-wrap gap-2">
<router-link to="/inventory/create" class="btn-primary text-sm">新建任务</router-link>
<button type="button" class="btn-secondary text-sm" :disabled="loading" @click="fetchTasks">
{{ loading ? '刷新中...' : '刷新' }}
</button>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">任务总数</p>
<p class="page-meta__value">{{ formatNumber(total) }}</p>
<p class="page-meta__subvalue">当前第 {{ currentPage }} / {{ totalPages }} 页</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前页进行中</p>
<p class="page-meta__value">{{ formatNumber(activeCount) }}</p>
<p class="page-meta__subvalue">已关闭 {{ formatNumber(closedCount) }} 个</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前页完成率</p>
<p class="page-meta__value">{{ formatPercent(currentPageCompletionRate) }}</p>
<p class="page-meta__subvalue">已核对 {{ formatNumber(currentPageCompletedCount) }} 件资产</p>
</div>
</div>
</section>
<section class="card">
<div class="filter-bar">
<div class="filter-bar__inputs" style="grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));">
<select v-model.number="pageSize" class="select" @change="handlePageSizeChange">
<option :value="10">10 条/页</option>
<option :value="20">20 条/页</option>
<option :value="50">50 条/页</option>
</select>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">盘点任务列表</h3>
<span class="badge badge-neutral">按创建时间倒序</span>
</div>
<div v-if="loading" class="empty-state">正在加载盘点任务...</div>
<template v-else>
<div class="mobile-card-list md:hidden">
<article
v-for="task in tasks"
:key="task.id"
class="mobile-card"
>
<div class="mobile-card__row">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-text-primary">{{ task.title }}</p>
<p class="mt-1 text-xs text-text-tertiary">
{{ resolveScopeLabel(task) }}
</p>
</div>
<span class="badge" :class="getTaskStatusClass(task.status)">{{ getTaskStatusText(task.status) }}</span>
</div>
<div class="mobile-card__meta mt-4">
<div>
<p class="mobile-card__label">任务进度</p>
<p class="mobile-card__value">{{ getTaskProgressLabel(task) }}</p>
</div>
<div>
<p class="mobile-card__label">异常 / 待分配</p>
<p class="mobile-card__value">{{ formatNumber(task.exception_count) }} / {{ formatNumber(task.unassigned_count) }}</p>
</div>
<div>
<p class="mobile-card__label">创建人</p>
<p class="mobile-card__value">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
</div>
<div>
<p class="mobile-card__label">创建时间</p>
<p class="mobile-card__value">{{ formatDateTime(task.created_at) }}</p>
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
<router-link :to="`/inventory/${task.id}`" class="btn-primary w-full text-sm">查看详情</router-link>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="btn-secondary text-xs" @click="copyTaskLink(task)">复制手机链接</button>
<button type="button" class="btn-secondary text-xs" @click="openTaskLink(task)">打开手机页</button>
</div>
</div>
</article>
<p v-if="tasks.length === 0" class="py-8 text-center text-sm text-text-tertiary">
还没有盘点任务,先创建一个吧。
</p>
</div>
<div class="hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>任务</th>
<th>范围</th>
<th>进度</th>
<th>异常 / 待分配</th>
<th>创建信息</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="task in tasks" :key="task.id">
<td>
<div class="space-y-2">
<div class="flex items-center gap-2">
<p class="font-medium text-text-primary">{{ task.title }}</p>
<span class="badge" :class="getTaskStatusClass(task.status)">
{{ getTaskStatusText(task.status) }}
</span>
</div>
<p class="text-xs text-text-secondary">
{{ task.description || '未填写任务说明' }}
</p>
</div>
</td>
<td>
<p class="text-sm text-text-primary">{{ resolveScopeLabel(task) }}</p>
<p class="mt-1 text-xs text-text-tertiary">
参与人 {{ formatNumber(task.participants_count) }} 名
</p>
</td>
<td>
<p class="text-sm text-text-primary">{{ getTaskProgressLabel(task) }}</p>
<p class="mt-1 text-xs text-text-tertiary">
异常 {{ formatNumber(task.exception_count) }} 件
</p>
</td>
<td>
<p class="text-sm text-text-primary">待分配 {{ formatNumber(task.unassigned_count) }} 件</p>
<p class="mt-1 text-xs text-text-tertiary">
总资产 {{ formatNumber(task.items_count) }} 件
</p>
</td>
<td>
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p>
<p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">关闭于 {{ formatDateTime(task.closed_at) }}</p>
</td>
<td>
<div class="table-action-group">
<router-link :to="`/inventory/${task.id}`" class="table-action table-action--primary">详情</router-link>
<button type="button" class="table-action" @click="copyTaskLink(task)">复制链接</button>
<button type="button" class="table-action" @click="openTaskLink(task)">打开手机页</button>
</div>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="6" class="py-8 text-center text-text-secondary">还没有盘点任务,先创建一个吧。</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="pagination-bar">
<p class="text-sm text-text-secondary">共 {{ formatNumber(total) }} 个任务</p>
<div class="flex items-center gap-2">
<button type="button" class="btn-secondary text-sm" :disabled="currentPage === 1" @click="handlePrevPage">上一页</button>
<span class="text-sm text-text-primary">{{ currentPage }} / {{ totalPages }}</span>
<button type="button" class="btn-secondary text-sm" :disabled="currentPage >= totalPages" @click="handleNextPage">下一页</button>
</div>
</div>
</template>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getInventoryTaskList } from '@/api/inventory'
import type { InventoryTaskListItem } from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
const tasks = ref<InventoryTaskListItem[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const activeCount = computed(() => tasks.value.filter((task) => task.status === 'active').length)
const closedCount = computed(() => tasks.value.filter((task) => task.status === 'closed').length)
const currentPageItemsCount = computed(() =>
tasks.value.reduce((sum, task) => sum + task.items_count, 0)
)
const currentPageCompletedCount = computed(() =>
tasks.value.reduce((sum, task) => sum + task.completed_count, 0)
)
const currentPageCompletionRate = computed(() => {
if (currentPageItemsCount.value === 0) return 0
return (currentPageCompletedCount.value / currentPageItemsCount.value) * 100
})
const buildTaskPublicUrl = (task: InventoryTaskListItem) =>
`${window.location.origin}/inventory/task/${task.public_token}`
const fetchTasks = async () => {
loading.value = true
try {
const response = await getInventoryTaskList({
page: currentPage.value,
pageSize: pageSize.value
})
tasks.value = response.data.list
total.value = response.data.total
} catch (error: any) {
alert(error?.message || '获取盘点任务失败')
} finally {
loading.value = false
}
}
const resolveScopeLabel = (task: InventoryTaskListItem) => {
const segments = []
if (task.scope_department_name) {
segments.push(`部门:${task.scope_department_name}`)
}
if (task.scope_asset_type_name) {
segments.push(`资产类型:${task.scope_asset_type_name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
}
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中')
const getTaskStatusClass = (status: string) => (status === 'closed' ? 'badge-neutral' : 'badge-success')
const getTaskProgressLabel = (task: InventoryTaskListItem) => {
if (task.items_count === 0) {
return '暂无资产'
}
const rate = (task.completed_count / task.items_count) * 100
return `${formatNumber(task.completed_count)} / ${formatNumber(task.items_count)} · ${formatPercent(rate)}`
}
const copyTaskLink = async (task: InventoryTaskListItem) => {
const link = buildTaskPublicUrl(task)
try {
await navigator.clipboard.writeText(link)
alert('手机盘点链接已复制')
} catch {
window.prompt('请复制这个手机盘点链接', link)
}
}
const openTaskLink = (task: InventoryTaskListItem) => {
window.open(buildTaskPublicUrl(task), '_blank', 'noopener,noreferrer')
}
const handlePageSizeChange = () => {
currentPage.value = 1
fetchTasks()
}
const handlePrevPage = () => {
if (currentPage.value <= 1) return
currentPage.value -= 1
fetchTasks()
}
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) return
currentPage.value += 1
fetchTasks()
}
onMounted(() => {
fetchTasks()
})
</script>

View File

@ -0,0 +1,794 @@
<template>
<div class="min-h-screen bg-bg-primary">
<div class="mx-auto w-full max-w-5xl px-4 py-4 sm:px-6 sm:py-6">
<div class="space-y-6 pb-24 md:pb-6">
<section class="page-header">
<div>
<p class="page-kicker">Mobile Inventory</p>
<h2 class="page-title">{{ displayedTask?.title || '手机盘点' }}</h2>
<p class="mt-2 text-xs text-text-secondary">
{{ displayedTask?.description || '确认身份后盘点资产' }}
</p>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">任务状态</p>
<p class="page-meta__value">{{ getTaskStatusText(displayedTask?.status || '') }}</p>
<p class="page-meta__subvalue">{{ scopeLabel }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">总资产</p>
<p class="page-meta__value">{{ formatNumber(displayedTask?.items_count || 0) }}</p>
<p class="page-meta__subvalue">异常 {{ formatNumber(displayedTask?.exception_count || 0) }} 件</p>
</div>
<div class="page-meta">
<p class="page-meta__label">我的进度</p>
<p class="page-meta__value">{{ formatPercent(myProgressPercent) }}</p>
<p class="page-meta__subvalue">待处理 {{ formatNumber(summary.pending) }} 件</p>
</div>
</div>
</section>
<section
v-if="feedback"
class="card"
:class="getFeedbackClass(feedback.type)"
>
<div class="flex items-start justify-between gap-3">
<p class="text-sm font-medium">{{ feedback.text }}</p>
<button type="button" class="text-xs text-text-tertiary" @click="feedback = null">关闭</button>
</div>
</section>
<section v-if="loadingTask && !task" class="card">
<div class="empty-state">正在加载盘点任务...</div>
</section>
<template v-else-if="task">
<section v-if="!session" class="card">
<div class="list-header">
<h3 class="section-title">确认身份</h3>
<span class="badge" :class="isTaskClosed ? 'badge-neutral' : 'badge-success'">
{{ isTaskClosed ? '任务已关闭,仅可查看结果' : '任务进行中' }}
</span>
</div>
<div class="space-y-3">
<div class="field-stack">
<label class="field-label">搜索姓名</label>
<input
v-model.trim="participantKeyword"
type="text"
class="input"
placeholder="输入花名或姓名"
/>
</div>
<div v-if="searchingParticipants" class="empty-state">正在搜索盘点参与人...</div>
<div v-else class="touch-option-list">
<button
v-for="participant in participants"
:key="participant.id"
type="button"
class="touch-option"
:class="{ 'is-active': selectedParticipantId === participant.id }"
@click="selectedParticipantId = participant.id"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ participant.department_name || '未归属部门' }}
</p>
</div>
<span class="badge" :class="getParticipantStatusClass(participant.status)">
{{ getParticipantStatusText(participant.status) }}
</span>
</div>
</button>
<p v-if="participants.length === 0" class="py-8 text-center text-sm text-text-tertiary">
没有搜到匹配的人,换个关键词试试。
</p>
</div>
<button
type="button"
class="btn-primary w-full"
:disabled="!selectedParticipantId || confirmingParticipant"
@click="confirmIdentity"
>
{{ confirmingParticipant ? '确认中...' : '确认身份' }}
</button>
</div>
</section>
<template v-else>
<section class="scanner-hero">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="page-kicker">Participant</p>
<h3 class="text-lg font-semibold text-text-primary">
{{ session.participant.person_nickname }} · {{ session.participant.person_name }}
</h3>
<p class="mt-1 text-xs text-text-secondary">
{{ session.participant.department_name || '未归属部门' }}
</p>
</div>
<div class="form-inline-actions">
<button
type="button"
class="btn-primary w-full text-sm sm:w-auto"
:disabled="isTaskClosed"
@click="showCameraScanner = true"
>
手机扫码
</button>
<button
type="button"
class="btn-secondary w-full text-sm sm:w-auto"
@click="showManualEntry = !showManualEntry"
>
{{ showManualEntry ? '收起' : '手动输入' }}
</button>
<button type="button" class="btn-secondary w-full text-sm sm:w-auto" @click="switchIdentity">
切换
</button>
</div>
</div>
<div class="scanner-hero__stats">
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">待处理</p>
<p class="scanner-hero__stat-value">{{ formatNumber(summary.pending) }}</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">已确认</p>
<p class="scanner-hero__stat-value">{{ formatNumber(summary.checked) }}</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">异常</p>
<p class="scanner-hero__stat-value">{{ formatNumber(summary.exception) }}</p>
</div>
</div>
<div v-if="showManualEntry" class="form-panel">
<div class="form-panel__header">
<h4 class="form-panel__title">手动输入</h4>
<p class="form-panel__desc">输入编码后提交</p>
</div>
<div class="field-stack">
<label class="field-label">资产编码</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
ref="scanInputRef"
v-model.trim="scanCode"
type="text"
class="input"
placeholder="资产编码"
:disabled="isTaskClosed"
@keyup.enter="handleSubmitScan"
/>
<button
type="button"
class="btn-secondary w-full text-sm sm:w-auto sm:min-w-[100px]"
:disabled="isTaskClosed || !scanCode || processingCode === scanCode"
@click="handleSubmitScan"
>
{{ processingCode === scanCode ? '提交中...' : '提交' }}
</button>
</div>
</div>
</div>
</section>
<section v-if="lastHandledItem" class="card border border-status-success/30 bg-status-success/10">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-status-success">最近完成</p>
<p class="mt-2 text-sm text-text-primary">
{{ lastHandledItem.equipment_name }}({{ lastHandledItem.equipment_code }})
</p>
<p class="mt-1 text-xs text-text-secondary">
当前状态:{{ getItemStatusText(lastHandledItem.check_status) }}
</p>
</div>
<span class="badge badge-success">{{ getItemStatusText(lastHandledItem.check_status) }}</span>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">我的盘点清单</h3>
<span class="badge badge-neutral">共 {{ formatNumber(filteredItems.length) }} 件</span>
</div>
<div class="filter-bar">
<div class="filter-bar__inputs">
<select v-model="itemFilter" class="select">
<option value="all">全部状态</option>
<option value="pending">待处理</option>
<option value="checked">已确认</option>
<option value="exception">异常</option>
</select>
<input
v-model.trim="itemKeyword"
type="text"
class="input"
placeholder="搜索资产编码或资产名称"
/>
</div>
</div>
<div class="mt-4 mobile-card-list md:hidden">
<article
v-for="item in filteredItems"
:key="item.id"
class="mobile-card"
>
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
</div>
<span class="badge" :class="getItemStatusClass(item.check_status)">
{{ getItemStatusText(item.check_status) }}
</span>
</div>
<div class="mobile-card__meta mt-4">
<div>
<p class="mobile-card__label">资产类型</p>
<p class="mobile-card__value">{{ item.asset_type_name || item.equipment_type }}</p>
</div>
<div>
<p class="mobile-card__label">归属部门</p>
<p class="mobile-card__value">{{ item.snapshot_department_name || '未归属' }}</p>
</div>
<div>
<p class="mobile-card__label">盘点时间</p>
<p class="mobile-card__value">{{ item.checked_at ? formatDateTime(item.checked_at) : '-' }}</p>
</div>
<div>
<p class="mobile-card__label">异常说明</p>
<p class="mobile-card__value">{{ getExceptionText(item.exception_type) }}</p>
</div>
</div>
<p v-if="item.exception_note" class="mt-4 text-sm text-text-secondary">
备注:{{ item.exception_note }}
</p>
<div
v-if="!isTaskClosed && item.check_status === 'pending'"
class="mt-4 grid gap-2 sm:grid-cols-2"
>
<button
type="button"
class="btn-primary w-full text-sm"
:disabled="processingCode === item.equipment_code"
@click="markItemChecked(item)"
>
{{ processingCode === item.equipment_code ? '提交中...' : '确认在我这里' }}
</button>
<button
type="button"
class="btn-secondary w-full text-sm"
@click="openExceptionModal(item)"
>
上报异常
</button>
</div>
</article>
<p v-if="filteredItems.length === 0" class="py-8 text-center text-sm text-text-tertiary">
当前筛选条件下没有资产。
</p>
</div>
<div class="mt-4 hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>资产</th>
<th>资产类型</th>
<th>状态</th>
<th>异常说明</th>
<th>盘点时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item.id">
<td>
<p class="text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
</td>
<td class="text-sm text-text-primary">{{ item.asset_type_name || item.equipment_type }}</td>
<td>
<span class="badge" :class="getItemStatusClass(item.check_status)">
{{ getItemStatusText(item.check_status) }}
</span>
</td>
<td class="text-sm text-text-secondary">
{{ getExceptionText(item.exception_type) }}
<span v-if="item.exception_note" class="mt-1 block text-xs text-text-tertiary">{{ item.exception_note }}</span>
</td>
<td class="text-sm text-text-secondary">
{{ item.checked_at ? formatDateTime(item.checked_at) : '-' }}
</td>
<td>
<div v-if="!isTaskClosed && item.check_status === 'pending'" class="table-action-group">
<button
type="button"
class="table-action table-action--primary"
:disabled="processingCode === item.equipment_code"
@click="markItemChecked(item)"
>
{{ processingCode === item.equipment_code ? '提交中...' : '确认在我这里' }}
</button>
<button type="button" class="table-action" @click="openExceptionModal(item)">上报异常</button>
</div>
<span v-else class="text-xs text-text-tertiary">-</span>
</td>
</tr>
<tr v-if="filteredItems.length === 0">
<td colspan="6" class="py-8 text-center text-text-secondary">当前筛选条件下没有资产。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>
</template>
<section v-else class="card">
<div class="empty-state">这个盘点任务不存在,或链接已经失效。</div>
</section>
</div>
</div>
<CameraScannerModal
v-model="showCameraScanner"
title="扫码盘点"
description="将资产二维码对准镜头,识别成功后会自动提交盘点。"
manual-placeholder="请输入资产编码"
@detected="handleCameraDetected"
/>
<div v-if="exceptionTarget" class="sheet-backdrop">
<div class="sheet-panel max-w-2xl">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-xl font-semibold text-text-primary">上报盘点异常</h3>
<p class="mt-1 text-sm text-text-secondary">
{{ exceptionTarget.equipment_name }}({{ exceptionTarget.equipment_code }})
</p>
</div>
<button type="button" class="btn-icon" @click="closeExceptionModal">✕</button>
</div>
<div class="mt-6 space-y-4">
<div class="field-stack">
<label class="field-label">异常类型</label>
<select v-model="exceptionForm.exception_type" class="select">
<option value="not_found">资产未找到</option>
<option value="not_with_me">资产不在本人处</option>
</select>
</div>
<div class="field-stack">
<label class="field-label">备注</label>
<textarea
v-model.trim="exceptionForm.exception_note"
class="input"
rows="3"
placeholder="可选,补充说明现场情况"
></textarea>
</div>
</div>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-end">
<button type="button" class="btn-secondary" @click="closeExceptionModal">取消</button>
<button
type="button"
class="btn-primary"
:disabled="exceptionSubmitting"
@click="submitException"
>
{{ exceptionSubmitting ? '提交中...' : '确认上报' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import {
confirmPublicInventoryParticipant,
getPublicInventoryItems,
getPublicInventoryMe,
getPublicInventoryTask,
searchPublicInventoryParticipants,
submitPublicInventoryException,
submitPublicInventoryScan
} from '@/api/publicInventory'
import type {
InventoryExceptionType,
InventoryTaskItem,
PublicInventoryItemsResponse,
PublicInventoryParticipantOption,
PublicInventorySessionData,
PublicInventoryTaskInfo
} from '@/types'
import { formatDateTime, formatNumber, formatPercent, playBeepSound, playErrorSound } from '@/utils/format'
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
type FeedbackType = 'success' | 'error' | 'info'
const route = useRoute()
const task = ref<PublicInventoryTaskInfo | null>(null)
const session = ref<PublicInventorySessionData | null>(null)
const participants = ref<PublicInventoryParticipantOption[]>([])
const summary = ref<PublicInventoryItemsResponse['summary']>({
total: 0,
pending: 0,
checked: 0,
exception: 0
})
const items = ref<InventoryTaskItem[]>([])
const lastHandledItem = ref<InventoryTaskItem | null>(null)
const loadingTask = ref(false)
const searchingParticipants = ref(false)
const confirmingParticipant = ref(false)
const exceptionSubmitting = ref(false)
const feedback = ref<{ type: FeedbackType; text: string } | null>(null)
const participantKeyword = ref('')
const selectedParticipantId = ref<number | null>(null)
const scanCode = ref('')
const showCameraScanner = ref(false)
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
const processingCode = ref('')
const itemFilter = ref<ItemFilter>('all')
const itemKeyword = ref('')
const scanInputRef = ref<HTMLInputElement>()
const exceptionTarget = ref<InventoryTaskItem | null>(null)
const exceptionForm = reactive<{
exception_type: InventoryExceptionType
exception_note: string
}>({
exception_type: 'not_found',
exception_note: ''
})
let participantSearchTimer: number | null = null
const taskToken = computed(() => String(route.params.token || ''))
const displayedTask = computed(() => {
if (!task.value) return null
if (!session.value || session.value.task.id !== task.value.id) return task.value
return {
...task.value,
...session.value.task,
description: task.value.description,
participants_count: task.value.participants_count,
unassigned_count: task.value.unassigned_count,
created_at: task.value.created_at
}
})
const isTaskClosed = computed(() => displayedTask.value?.status === 'closed')
const scopeLabel = computed(() => {
if (!displayedTask.value) return '等待加载任务信息'
const segments = []
if (displayedTask.value.scope_department_name) {
segments.push(`部门:${displayedTask.value.scope_department_name}`)
}
if (displayedTask.value.scope_asset_type_name) {
segments.push(`资产类型:${displayedTask.value.scope_asset_type_name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
})
const myProgressPercent = computed(() => {
if (summary.value.total === 0) return 0
return ((summary.value.checked + summary.value.exception) / summary.value.total) * 100
})
const filteredItems = computed(() => {
const keyword = itemKeyword.value.trim().toLowerCase()
return items.value.filter((item) => {
const matchesFilter = itemFilter.value === 'all' || item.check_status === itemFilter.value
const matchesKeyword =
!keyword ||
item.equipment_code.toLowerCase().includes(keyword) ||
item.equipment_name.toLowerCase().includes(keyword)
return matchesFilter && matchesKeyword
})
})
const resetSessionState = () => {
session.value = null
items.value = []
summary.value = {
total: 0,
pending: 0,
checked: 0,
exception: 0
}
}
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中')
const getParticipantStatusText = (status: string) => {
const mapping: Record<string, string> = {
pending: '待确认',
in_progress: '盘点中',
completed: '已完成'
}
return mapping[status] || status
}
const getParticipantStatusClass = (status: string) => {
const mapping: Record<string, string> = {
pending: 'badge-warning',
in_progress: 'badge-neutral',
completed: 'badge-success'
}
return mapping[status] || 'badge-neutral'
}
const getItemStatusText = (status: string) => {
const mapping: Record<string, string> = {
pending: '待处理',
checked: '已确认',
exception: '异常'
}
return mapping[status] || status
}
const getItemStatusClass = (status: string) => {
const mapping: Record<string, string> = {
pending: 'badge-warning',
checked: 'badge-success',
exception: 'badge-error'
}
return mapping[status] || 'badge-neutral'
}
const getExceptionText = (exceptionType?: string | null) => {
const mapping: Record<string, string> = {
not_found: '资产未找到',
not_with_me: '资产不在本人处'
}
return exceptionType ? mapping[exceptionType] || exceptionType : '无'
}
const getFeedbackClass = (type: FeedbackType) => {
const mapping: Record<FeedbackType, string> = {
success: 'border border-status-success/30 bg-status-success/10 text-status-success',
error: 'border border-status-error/30 bg-status-error/10 text-status-error',
info: 'border border-border bg-bg-secondary text-text-secondary'
}
return mapping[type]
}
const setFeedback = (type: FeedbackType, text: string) => {
feedback.value = { type, text }
}
const loadTaskInfo = async () => {
const response = await getPublicInventoryTask(taskToken.value)
task.value = response.data
}
const loadSessionAndItems = async () => {
try {
const [sessionResponse, itemsResponse] = await Promise.all([
getPublicInventoryMe(),
getPublicInventoryItems()
])
if (task.value && sessionResponse.data.task.id !== task.value.id) {
resetSessionState()
return false
}
session.value = sessionResponse.data
items.value = itemsResponse.data.items
summary.value = itemsResponse.data.summary
return true
} catch {
resetSessionState()
return false
}
}
const searchParticipantsNow = async (keyword = participantKeyword.value) => {
if (!taskToken.value || session.value) return
searchingParticipants.value = true
try {
const response = await searchPublicInventoryParticipants(taskToken.value, keyword.trim())
participants.value = response.data
} catch (error: any) {
setFeedback('error', error?.message || '搜索盘点参与人失败')
} finally {
searchingParticipants.value = false
}
}
const bootstrap = async () => {
loadingTask.value = true
task.value = null
resetSessionState()
participants.value = []
selectedParticipantId.value = null
lastHandledItem.value = null
try {
await loadTaskInfo()
const hasSession = await loadSessionAndItems()
if (!hasSession) {
await searchParticipantsNow('')
}
} catch (error: any) {
setFeedback('error', error?.message || '获取盘点任务失败')
} finally {
loadingTask.value = false
}
}
const confirmIdentity = async () => {
if (!selectedParticipantId.value) {
setFeedback('error', '请先选择你的身份')
return
}
confirmingParticipant.value = true
try {
await confirmPublicInventoryParticipant(taskToken.value, selectedParticipantId.value)
const hasSession = await loadSessionAndItems()
if (!hasSession) {
throw new Error('身份确认后未能建立盘点会话,请重试')
}
setFeedback('success', '身份确认成功,现在可以开始盘点了')
await nextTick()
scanInputRef.value?.focus()
} catch (error: any) {
setFeedback('error', error?.message || '确认身份失败')
} finally {
confirmingParticipant.value = false
}
}
const refreshAfterAction = async (handledItemId?: number) => {
await loadTaskInfo()
await loadSessionAndItems()
if (handledItemId) {
lastHandledItem.value = items.value.find((item) => item.id === handledItemId) || null
}
}
const submitCheckByCode = async (equipmentCode: string) => {
const normalizedCode = equipmentCode.trim()
if (!normalizedCode) {
setFeedback('error', '请输入资产编码')
return
}
processingCode.value = normalizedCode
try {
const response = await submitPublicInventoryScan(normalizedCode)
playBeepSound()
scanCode.value = ''
await refreshAfterAction(response.data.id)
setFeedback('success', `已确认 ${response.data.equipment_name}`)
await nextTick()
scanInputRef.value?.focus()
} catch (error: any) {
playErrorSound()
setFeedback('error', error?.message || '提交盘点结果失败')
} finally {
processingCode.value = ''
}
}
const handleSubmitScan = async () => {
if (isTaskClosed.value) {
setFeedback('error', '盘点任务已关闭,不能继续提交结果')
return
}
await submitCheckByCode(scanCode.value)
}
const handleCameraDetected = async (value: string) => {
scanCode.value = value.trim()
await handleSubmitScan()
}
const markItemChecked = async (item: InventoryTaskItem) => {
if (isTaskClosed.value) {
setFeedback('error', '盘点任务已关闭,不能继续提交结果')
return
}
await submitCheckByCode(item.equipment_code)
}
const openExceptionModal = (item: InventoryTaskItem) => {
exceptionTarget.value = item
exceptionForm.exception_type = 'not_found'
exceptionForm.exception_note = ''
}
const closeExceptionModal = () => {
exceptionTarget.value = null
exceptionForm.exception_type = 'not_found'
exceptionForm.exception_note = ''
}
const submitException = async () => {
if (!exceptionTarget.value) return
exceptionSubmitting.value = true
try {
await submitPublicInventoryException(exceptionTarget.value.id, {
exception_type: exceptionForm.exception_type,
exception_note: exceptionForm.exception_note || undefined
})
await refreshAfterAction(exceptionTarget.value.id)
setFeedback('success', `已为 ${exceptionTarget.value.equipment_name} 提交异常`)
closeExceptionModal()
} catch (error: any) {
setFeedback('error', error?.message || '提交盘点异常失败')
} finally {
exceptionSubmitting.value = false
}
}
const switchIdentity = async () => {
resetSessionState()
selectedParticipantId.value = null
setFeedback('info', '你可以重新选择自己的身份。')
await searchParticipantsNow('')
}
watch(
() => participantKeyword.value,
(value) => {
if (participantSearchTimer) {
window.clearTimeout(participantSearchTimer)
}
participantSearchTimer = window.setTimeout(() => {
searchParticipantsNow(value)
}, 250)
}
)
watch(
() => route.params.token,
() => {
bootstrap()
}
)
onMounted(() => {
bootstrap()
})
onUnmounted(() => {
if (participantSearchTimer) {
window.clearTimeout(participantSearchTimer)
}
})
</script>

View File

@ -6,16 +6,16 @@
<p class="page-kicker">Inbox</p>
<h2 class="page-title">站内消息</h2>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<div class="flex gap-3">
<div class="page-meta" style="min-width: 120px;">
<p class="page-meta__label">未读消息</p>
<p class="page-meta__value">{{ notificationStore.unreadCount }}</p>
<p class="page-meta__subvalue">待行政查看</p>
<p class="page-meta__subvalue">待查看</p>
</div>
<div class="page-meta">
<div class="page-meta" style="min-width: 120px;">
<p class="page-meta__label">当前筛选</p>
<p class="page-meta__value">{{ statusFilter === 'unread' ? '仅未读' : '全部消息' }}</p>
<p class="page-meta__subvalue">按同步结果自动沉淀</p>
<p class="page-meta__value">{{ statusFilter === 'unread' ? '仅未读' : '全部' }}</p>
<p class="page-meta__subvalue">自动沉淀</p>
</div>
</div>
</section>
@ -200,7 +200,8 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
department_renamed: '部门名称变动',
department_hierarchy_changed: '部门层级变动',
department_deactivated: '部门停用',
department_reactivated: '部门重新启用'
department_reactivated: '部门重新启用',
equipment_department_unresolved: '资产部门待处理'
}
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))

View File

@ -5,28 +5,27 @@
<p class="page-kicker">System Configuration</p>
<h2 class="page-title">系统设置</h2>
</div>
<div class="page-meta-grid">
<div class="panel">
<p class="text-xs text-text-secondary">配置模块</p>
<p class="mt-1 text-xl font-semibold text-text-primary">{{ tabs.length }}</p>
<p class="text-xs text-text-tertiary">覆盖基础数据与流程管理</p>
<div class="flex gap-2">
<div class="panel" style="min-width: 80px;">
<p class="text-xs text-text-secondary">模块</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ tabs.length }}</p>
</div>
<div class="panel">
<p class="text-xs text-text-secondary">当前模块</p>
<p class="mt-1 text-base font-semibold text-text-primary sm:text-lg">{{ activeTabLabel }}</p>
<p class="text-xs text-text-tertiary">已按标签分类整理</p>
<div class="panel" style="min-width: 80px;">
<p class="text-xs text-text-secondary">当前</p>
<p class="mt-1 text-sm font-semibold text-text-primary">{{ activeTabLabel }}</p>
</div>
</div>
</section>
<section class="card">
<div class="overflow-x-auto">
<div class="segmented-toggle min-w-max">
<div class="segmented-toggle min-w-max" style="padding: 2px;">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="segmented-toggle__item"
style="min-height: 32px; padding: 0 12px; font-size: 12px;"
:class="{ 'is-active': activeTab === tab.key }"
@click="switchTab(tab.key)"
>
@ -81,10 +80,10 @@ const tabs: Array<{ key: TabKey; label: string }> = [
{ key: 'site', label: '站点信息' },
{ key: 'users', label: '用户管理' },
{ key: 'sync', label: '组织同步' },
{ key: 'persons', label: '使用者信息' },
{ key: 'persons', label: '人员维护' },
{ key: 'locations', label: '地址管理' },
{ key: 'assetTypes', label: '资产类型' },
{ key: 'departments', label: '使用部门' },
{ key: 'departments', label: '组织架构' },
{ key: 'maintenance', label: '维修管理' },
{ key: 'disposal', label: '报废管理' },
{ key: 'logs', label: '操作日志' }
@ -93,7 +92,15 @@ const tabs: Array<{ key: TabKey; label: string }> = [
const route = useRoute()
const router = useRouter()
const initialTab = (route.query.tab as TabKey) || 'site'
const normalizeTab = (value?: string | null): TabKey => {
if (value === 'organization') {
return 'departments'
}
return tabs.some((tab) => tab.key === value) ? (value as TabKey) : 'site'
}
const initialTab = normalizeTab(route.query.tab as string | undefined)
const activeTab = ref<TabKey>(tabs.some((t) => t.key === initialTab) ? initialTab : 'site')
const activeTabLabel = computed(() => tabs.find((tab) => tab.key === activeTab.value)?.label || '站点信息')
@ -112,9 +119,7 @@ watch(
() => route.query.tab,
(tab) => {
if (!tab) return
if (tabs.some((t) => t.key === tab)) {
activeTab.value = tab as TabKey
}
activeTab.value = normalizeTab(tab as string)
}
)
</script>

View File

@ -3,7 +3,7 @@
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<div class="filter-bar__inputs">
<input
v-model.trim="filters.keyword"
class="input"
@ -11,8 +11,6 @@
placeholder="输入类型名称关键字"
@keyup.enter="fetchTypes"
/>
</div>
<div class="flex flex-wrap gap-2 sm:gap-3">
<select v-model="filters.status" class="select">
<option value="">全部状态</option>
<option value="active">启用</option>

View File

@ -30,60 +30,55 @@
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="section-title">同步预览</h3>
<p class="mt-1 text-sm text-text-tertiary">
先看本次会同步多少数据,再决定是否执行。
</p>
<p class="mt-1 text-xs text-text-tertiary">查看本次待同步数据范围</p>
</div>
<span class="badge" :class="statusBadgeClass">{{ statusBadgeText }}</span>
</div>
<div
v-if="statusLoading"
class="mt-5 rounded-lg border border-border bg-bg-elevated px-4 py-5 text-sm text-text-tertiary"
class="mt-4 rounded-lg border border-border bg-bg-elevated px-3 py-4 text-sm text-text-tertiary"
>
正在检查同步配置...
正在检查配置...
</div>
<div
v-else-if="!syncReady"
class="mt-5 rounded-lg border border-status-warning/30 bg-status-warning/10 px-4 py-5"
class="mt-4 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-4"
>
<p class="text-sm font-medium text-text-primary">同步源库还没有配置完成</p>
<p class="mt-2 text-sm text-text-secondary">
<p class="text-sm font-medium text-text-primary">源库尚未配置完成</p>
<p class="mt-1 text-xs text-text-secondary">
{{ configurationMessage }}
</p>
<p class="mt-2 text-xs text-text-tertiary">
这一步需要先在服务器环境配置里补齐源库连接信息,当前不会自动执行同步。
</p>
</div>
<div
v-else-if="previewErrorMessage"
class="mt-5 rounded-lg border border-status-error/30 bg-status-error/10 px-4 py-5"
class="mt-4 rounded-lg border border-status-error/30 bg-status-error/10 px-3 py-4"
>
<p class="text-sm font-medium text-text-primary">读取预览失败</p>
<p class="mt-2 text-sm text-text-secondary">{{ previewErrorMessage }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ previewErrorMessage }}</p>
</div>
<div v-else-if="previewData" class="mt-5 grid gap-3 sm:grid-cols-2">
<div v-else-if="previewData" class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">同步部门</p>
<p class="mt-2 text-2xl font-semibold text-text-primary">{{ previewData.departments.total }}</p>
<p class="mt-2 text-sm text-text-secondary">
源库部门总数 {{ previewData.departments.raw_total }},本次按人员范围筛出 {{ departmentCoverage }}%
<p class="mt-1 text-xl font-semibold text-text-primary">{{ previewData.departments.total }}</p>
<p class="mt-1 text-xs text-text-secondary">
候选 {{ previewData.departments.raw_total }} 个,沉淀 {{ departmentCoverage }}%
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">同步人员</p>
<p class="mt-2 text-2xl font-semibold text-text-primary">{{ previewData.persons.total }}</p>
<p class="mt-2 text-sm text-text-secondary">
只保留正式员工,后续借还与扫码都会从这里选人。
<p class="mt-1 text-xl font-semibold text-text-primary">{{ previewData.persons.total }}</p>
<p class="mt-1 text-xs text-text-secondary">
{{ sourceModeLabel }} · {{ bucketSummaryText }}
</p>
</div>
</div>
<p v-else class="mt-5 rounded-lg border border-dashed border-border px-4 py-5 text-sm text-text-tertiary">
还没有读取预览。点击"刷新预览"后,会先从源库拉一遍待同步数据范围。
<p v-else class="mt-4 rounded-lg border border-dashed border-border px-3 py-4 text-sm text-text-tertiary">
点击"刷新"读取预览数据
</p>
</div>
@ -91,30 +86,28 @@
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="section-title">最近执行结果</h3>
<p class="mt-1 text-sm text-text-tertiary">
真正执行同步后,这里会显示本次新增、更新和停用数量。
</p>
<p class="mt-1 text-xs text-text-tertiary">显示新增/更新/停用数量</p>
</div>
<span v-if="lastRunAt" class="text-sm text-text-tertiary">完成于 {{ lastRunAt }}</span>
<span v-if="lastRunAt" class="text-xs text-text-tertiary">{{ lastRunAt }}</span>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行方式</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ syncStatus?.enabled ? '已启用定时计划' : '当前仅手动执行' }}
<p class="mt-1 text-sm font-semibold text-text-primary">
{{ syncStatus?.enabled ? '定时计划' : '手动执行' }}
</p>
<p class="mt-2 text-sm text-text-secondary">
<p class="mt-1 text-xs text-text-secondary">
{{ scheduleSummary }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">运行状态</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ syncReady ? '源库连接已就绪' : '等待补齐配置' }}
<p class="mt-1 text-sm font-semibold text-text-primary">
{{ syncReady ? '已就绪' : '待配置' }}
</p>
<p class="mt-2 text-sm text-text-secondary">
{{ syncReady ? '可以先刷新预览,再执行正式同步。' : '当前先不会执行同步,避免误报错。' }}
<p class="mt-1 text-xs text-text-secondary">
{{ syncReady ? '可预览并执行同步' : '需补齐配置' }}
</p>
</div>
</div>
@ -150,15 +143,15 @@
<div class="mt-4 space-y-3">
<div
v-for="department in previewData.departments.sample"
:key="department.sourceGroupId"
:key="department.name"
class="panel"
>
<div class="flex items-center justify-between gap-3">
<p class="font-medium text-text-primary">{{ department.name }}</p>
<span class="text-xs text-text-tertiary">源ID {{ department.sourceGroupId }}</span>
<span class="text-xs text-text-tertiary">来源人数 {{ department.sourcePersonCount }}</span>
</div>
<p class="mt-2 text-sm text-text-secondary">
上级源ID:{{ department.sourceParentGroupId ?? '顶级部门' }}
同步桶:{{ department.buckets.map(getDepartmentBucketLabel).join('、') }}
</p>
</div>
</div>
@ -179,16 +172,24 @@
<div>
<p class="font-medium text-text-primary">{{ person.nickname }} / {{ person.realName }}</p>
<p class="mt-1 text-sm text-text-secondary">
{{ person.departmentName || '未回填部门名称' }}
{{ person.resolvedDepartmentName || '未解析业务部门' }}
<span v-if="person.position"> · {{ person.position }}</span>
</p>
</div>
<span class="badge" :class="person.status === 'active' ? 'badge-success' : 'badge-neutral'">
{{ person.status === 'active' ? '在职' : '停用' }}
</span>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-neutral">
{{ getSourcePresenceLabel(person.sourcePresence) }}
</span>
<span class="badge badge-success">
{{ getDepartmentBucketLabel(person.departmentSyncBucket) }}
</span>
</div>
</div>
<p class="mt-2 text-xs text-text-tertiary">
主部门源ID:{{ person.sourceMainDepartmentId ?? '-' }},部门列表:{{ person.sourceDeptIds || '-' }}
sys00-dept:{{ person.sourceDepartmentName || '-' }};sys00-mainDepts:{{ person.sourceMainDepartmentName || '-' }}
</p>
<p class="mt-1 text-xs text-text-tertiary">
主部门源ID:{{ person.sourceMainDepartmentId ?? '-' }};部门列表:{{ person.sourceDeptIds || '-' }}
</p>
</div>
</div>
@ -503,7 +504,8 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
department_renamed: '部门名称变动',
department_hierarchy_changed: '部门层级变动',
department_deactivated: '部门停用',
department_reactivated: '部门重新启用'
department_reactivated: '部门重新启用',
equipment_department_unresolved: '资产部门待处理'
}
const CHANGE_PROCESSING_LABELS: Record<HrSyncChangeProcessingStatus, string> = {
@ -525,6 +527,17 @@ const departmentCoverage = computed(() => {
return Math.round((previewData.value.departments.total / previewData.value.departments.raw_total) * 100)
})
const sourceModeLabel = computed(() => {
if (!previewData.value) return '登记表主口径'
return previewData.value.source_mode === 'register_primary' ? '登记表主口径' : previewData.value.source_mode
})
const bucketSummaryText = computed(() => {
if (!previewData.value) return ''
const summary = previewData.value.persons.department_bucket_summary
return `正式 ${summary.formal} 人,正式兜底 ${summary.fallback} 人,其他 ${summary.other} 人`
})
const syncReady = computed(() => Boolean(syncStatus.value?.complete))
const missingFieldLabels = computed(() =>
@ -600,6 +613,19 @@ const summaryPairs = (job: Pick<HrSyncJobHistoryItem, 'counts'>) =>
count: Number(count)
}))
const getDepartmentBucketLabel = (bucket: 'formal' | 'fallback' | 'other' | string) => {
if (bucket === 'formal') return '正式员工'
if (bucket === 'fallback') return '正式员工兜底'
if (bucket === 'other') return '其他'
return bucket
}
const getSourcePresenceLabel = (presence: 'employee_and_register' | 'register_only' | string) => {
if (presence === 'employee_and_register') return '登记+员工'
if (presence === 'register_only') return '仅登记表'
return presence
}
const getJobStatusLabel = (status?: string) => (status === 'failed' ? '失败' : '成功')
const getJobStatusBadgeClass = (status?: string) => (status === 'failed' ? 'badge-error' : 'badge-success')

View File

@ -1,68 +1,59 @@
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Audit Log</p>
<h2 class="page-title">操作日志</h2>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">资产操作</p>
<p class="page-meta__value">{{ logSummary.assetOps }}</p>
<div class="form-shell">
<section class="form-hero">
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="field-stack">
<label class="field-label">操作类型</label>
<select v-model="filters.operationType" class="select" @change="handleSearch">
<option value="">全部类型</option>
<option v-for="type in operationTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="page-meta">
<p class="page-meta__label">借还流转</p>
<p class="page-meta__value">{{ logSummary.borrowOps }}</p>
<div class="field-stack">
<label class="field-label">操作人</label>
<input v-model.trim="filters.username" type="text" class="input" placeholder="输入用户名或姓名" @keyup.enter="handleSearch" />
</div>
<div class="page-meta">
<p class="page-meta__label">风险处置</p>
<p class="page-meta__value">{{ logSummary.riskOps }}</p>
<div class="field-stack">
<label class="field-label">开始日期</label>
<input v-model="filters.startDate" type="date" class="input" />
</div>
<div class="field-stack">
<label class="field-label">结束日期</label>
<input v-model="filters.endDate" type="date" class="input" />
</div>
</div>
</section>
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="filter-bar__inputs">
<div class="field-stack">
<label class="field-label">操作类型</label>
<select v-model="filters.operationType" class="select" @change="handleSearch">
<option value="">全部类型</option>
<option v-for="type in operationTypes" :key="type" :value="type">{{ type }}</option>
</select>
<div class="mt-5 flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div class="grid flex-1 gap-3 sm:grid-cols-3">
<div class="page-meta">
<p class="page-meta__label">资产操作</p>
<p class="page-meta__value">{{ logSummary.assetOps }}</p>
</div>
<div class="field-stack">
<label class="field-label">操作人</label>
<input v-model.trim="filters.username" type="text" class="input" placeholder="输入用户名或姓名" @keyup.enter="handleSearch" />
<div class="page-meta">
<p class="page-meta__label">借还流转</p>
<p class="page-meta__value">{{ logSummary.borrowOps }}</p>
</div>
<div class="field-stack">
<label class="field-label">开始日期</label>
<input v-model="filters.startDate" type="date" class="input" />
</div>
<div class="field-stack">
<label class="field-label">结束日期</label>
<input v-model="filters.endDate" type="date" class="input" />
<div class="page-meta">
<p class="page-meta__label">风险处置</p>
<p class="page-meta__value">{{ logSummary.riskOps }}</p>
</div>
</div>
<div class="filter-bar__actions">
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="handleReset">重置</button>
<button class="btn-primary text-xs sm:text-sm" :disabled="loading" @click="handleSearch">
<div class="form-inline-actions">
<button class="btn-secondary" :disabled="loading" @click="handleReset">重置</button>
<button class="btn-primary" :disabled="loading" @click="handleSearch">
{{ loading ? '查询中...' : '查询' }}
</button>
</div>
</div>
</section>
<!-- 数据列表 -->
<section class="card">
<div class="list-header">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 class="section-title">操作日志</h3>
<span class="badge badge-neutral">已加载 {{ logs.length }} 条</span>
</div>
<!-- 移动端卡片 -->
<div class="mobile-card-list md:hidden">
<article v-for="log in logs" :key="`mobile-${log.id}`" class="mobile-card">
<div class="mobile-card__row">
@ -73,7 +64,7 @@
<span class="badge" :class="getTypeBadgeClass(log.operation_type)">{{ log.operation_type }}</span>
</div>
<div class="mobile-card__meta">
<div class="mt-4 mobile-card__meta">
<div>
<p class="mobile-card__label">资产编码</p>
<p class="mobile-card__value font-mono">{{ log.equipment_code || '-' }}</p>
@ -84,20 +75,19 @@
</div>
</div>
<div class="mt-4 rounded-lg border border-border bg-bg-elevated px-4 py-3">
<div class="mt-4 rounded-2xl border border-border bg-bg-elevated px-4 py-4">
<p class="mobile-card__label">操作详情</p>
<p class="mobile-card__value">{{ log.operation_details || '未记录详情' }}</p>
</div>
</article>
<p v-if="!loading && logs.length === 0" class="text-center text-sm text-text-tertiary py-8">
<p v-if="!loading && logs.length === 0" class="rounded-2xl border border-border bg-bg-elevated px-4 py-8 text-center text-sm text-text-tertiary">
当前筛选条件下暂无操作日志。
</p>
</div>
<!-- 桌面端表格 -->
<div class="hidden md:block">
<div class="table-container">
<div class="table-container hidden md:block">
<div class="overflow-x-auto">
<table class="data-table min-w-[1040px]">
<thead>
<tr>
@ -130,12 +120,11 @@
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="pagination-bar">
<div v-if="totalPages > 1" class="mt-4 flex items-center justify-between border-t border-border pt-4">
<p class="text-sm text-text-secondary">第 {{ currentPage }} / {{ totalPages }} 页</p>
<div class="flex items-center gap-2">
<button class="btn-secondary text-sm" :disabled="currentPage === 1 || loading" @click="handlePrevPage">上一页</button>
<button class="btn-secondary text-sm" :disabled="currentPage === totalPages || loading" @click="handleNextPage">下一页</button>
<button @click="handlePrevPage" :disabled="currentPage === 1" class="btn-secondary text-sm">上一页</button>
<button @click="handleNextPage" :disabled="currentPage === totalPages" class="btn-secondary text-sm">下一页</button>
</div>
</div>
</section>

View File

@ -1,29 +1,73 @@
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Personnel Management</p>
<h2 class="page-title">人员维护</h2>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">人员总数</p>
<p class="page-meta__value">{{ summary.total }}</p>
<p class="page-meta__subvalue">启用 {{ summary.active }} / 禁用 {{ summary.inactive }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已同步</p>
<p class="page-meta__value">{{ summary.synced }}</p>
<p class="page-meta__subvalue">本地 {{ summary.local }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">未归属</p>
<p class="page-meta__value text-status-warning">{{ summary.unassigned }}</p>
<p class="page-meta__subvalue">未挂部门</p>
</div>
</div>
</section>
<!-- 反馈通知区域 -->
<section v-if="notification.message" class="card" :class="notification.type === 'error' ? 'border-status-error/30' : 'border-status-success/30'">
<div class="flex items-start gap-3">
<span class="text-xl" :class="notification.type === 'error' ? 'text-status-error' : 'text-status-success'">
{{ notification.type === 'error' ? '!' : '✓' }}
</span>
<div class="flex-1">
<p class="font-medium" :class="notification.type === 'error' ? 'text-status-error' : 'text-status-success'">
{{ notification.title }}
</p>
<p class="mt-1 text-sm text-text-secondary">{{ notification.message }}</p>
</div>
<button class="btn-icon" @click="clearNotification">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</section>
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<div class="filter-bar__inputs">
<input
v-model.trim="keyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 证件编号"
placeholder="输入花名 / 姓名 / 工号"
@keyup.enter="handleSearch"
/>
</div>
<div class="flex flex-wrap gap-2 sm:gap-3">
<select v-model="statusFilter" class="select" @change="handleSearch">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
<button class="btn-secondary text-xs sm:text-sm" :disabled="listLoading" @click="handleSearch">查询</button>
<button class="btn-secondary text-xs sm:text-sm" @click="goToSync">同步</button>
<button class="btn-primary text-xs sm:text-sm" @click="showCreateModal = true">新增</button>
<button class="btn-primary text-xs sm:text-sm" @click="showCreateModal = true">新增人员</button>
</div>
</div>
<div class="flex flex-wrap gap-2 sm:gap-3 mt-4 pt-4 border-t border-border">
<button class="btn-secondary text-xs sm:text-sm" @click="goToDepartments">组织架构</button>
<button class="btn-secondary text-xs sm:text-sm" @click="goToSync">组织同步</button>
<button class="btn-secondary text-xs sm:text-sm" @click="downloadTemplate">下载模板</button>
<label class="btn-primary text-xs sm:text-sm cursor-pointer">
批量导入
@ -75,10 +119,10 @@
</div>
<div class="mt-3 table-action-group">
<button class="table-action table-action--primary" @click="openEdit(person)">编辑</button>
<button class="table-action" @click="toggleStatus(person)">
<button class="table-action" @click="confirmToggleStatus(person)">
{{ person.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDeletePerson(person)">删除</button>
<button class="table-action table-action--danger" @click="confirmDelete(person)">删除</button>
</div>
</div>
</div>
@ -91,7 +135,7 @@
<tr>
<th>花名</th>
<th>真实姓名</th>
<th>证件编号</th>
<th>工号</th>
<th>部门 / 职位</th>
<th>联系方式</th>
<th>状态</th>
@ -140,10 +184,10 @@
<td class="text-right">
<div class="table-action-group justify-end">
<button class="table-action table-action--primary" @click="openEdit(person)">编辑</button>
<button class="table-action" @click="toggleStatus(person)">
<button class="table-action" @click="confirmToggleStatus(person)">
{{ person.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDeletePerson(person)">删除</button>
<button class="table-action table-action--danger" @click="confirmDelete(person)">删除</button>
</div>
</td>
</tr>
@ -182,8 +226,8 @@
<input v-model.trim="newPerson.real_name" class="input" type="text" placeholder="真实姓名" />
</div>
<div class="field-stack">
<label class="field-label">证件编号</label>
<input v-model.trim="newPerson.id_number" class="input" type="text" placeholder="身份证 / 工号(可选)" />
<label class="field-label">工号</label>
<input v-model.trim="newPerson.id_number" class="input" type="text" placeholder="请输入工号(可选)" />
</div>
<div class="field-stack">
<label class="field-label">部门</label>
@ -236,7 +280,7 @@
<input v-model.trim="editDraft.real_name" class="input" type="text" />
</div>
<div class="field-stack">
<label class="field-label">证件编号</label>
<label class="field-label">工号</label>
<input v-model.trim="editDraft.id_number" class="input" type="text" />
</div>
<div class="field-stack">
@ -275,6 +319,24 @@
</div>
</div>
</div>
<!-- 确认对话框 -->
<div v-if="confirmDialog.show" class="sheet-backdrop">
<div class="sheet-panel max-w-md">
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ confirmDialog.title }}</h3>
<p class="text-text-secondary">{{ confirmDialog.message }}</p>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeConfirmDialog">取消</button>
<button
:class="confirmDialog.actionType === 'danger' ? 'btn-danger' : 'btn-primary'"
:disabled="confirmDialog.loading"
@click="executeConfirmAction"
>
{{ confirmDialog.loading ? '处理中...' : confirmDialog.confirmText }}
</button>
</div>
</div>
</div>
</div>
</template>
@ -297,7 +359,36 @@ const createLoading = ref(false)
const updateLoading = ref(false)
const showCreateModal = ref(false)
const keyword = ref('')
const statusFilter = ref('')
const statusFilter = ref('active')
// 汇总统计
const summary = reactive({
total: 0,
active: 0,
inactive: 0,
synced: 0,
local: 0,
unassigned: 0
})
// 通知反馈
const notification = reactive({
show: false,
type: 'success' as 'success' | 'error',
title: '',
message: ''
})
// 确认对话框
const confirmDialog = reactive({
show: false,
title: '',
message: '',
confirmText: '确认',
actionType: 'primary' as 'primary' | 'danger',
loading: false,
action: null as (() => Promise<void>) | null
})
const newPerson = reactive({
nickname: '', real_name: '', id_number: '', department: '', position: '', phone: '', email: '', status: 'active', remarks: ''
@ -313,10 +404,66 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
const formatSyncTime = (value?: string | null) => (value ? formatDateTime(value) : '未同步')
const goToDepartments = () => {
router.push({ path: '/settings', query: { tab: 'departments' } })
}
const goToSync = () => {
router.push({ path: '/settings', query: { tab: 'sync' } })
}
const showNotification = (type: 'success' | 'error', title: string, message: string) => {
notification.type = type
notification.title = title
notification.message = message
notification.show = true
// 5秒后自动清除
setTimeout(() => {
if (notification.message === message) {
clearNotification()
}
}, 5000)
}
const clearNotification = () => {
notification.show = false
notification.message = ''
notification.title = ''
}
const openConfirmDialog = (
title: string,
message: string,
confirmText: string,
actionType: 'primary' | 'danger',
action: () => Promise<void>
) => {
confirmDialog.title = title
confirmDialog.message = message
confirmDialog.confirmText = confirmText
confirmDialog.actionType = actionType
confirmDialog.action = action
confirmDialog.loading = false
confirmDialog.show = true
}
const closeConfirmDialog = () => {
confirmDialog.show = false
confirmDialog.action = null
}
const executeConfirmAction = async () => {
if (!confirmDialog.action) return
confirmDialog.loading = true
try {
await confirmDialog.action()
closeConfirmDialog()
} finally {
confirmDialog.loading = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchPersons()
@ -335,13 +482,34 @@ const fetchPersons = async () => {
persons.value = response.data.list
total.value = response.data.total
}
} catch (error) {
console.error('获取使用者列表失败', error)
} catch (error: any) {
showNotification('error', '获取数据失败', error?.message || '无法获取使用者列表')
} finally {
listLoading.value = false
}
}
const fetchSummary = async () => {
try {
// 获取全部数据用于统计
const response = await getPersonList({
page: 1,
pageSize: 1000
})
if (response.data) {
const allPersons = response.data.list
summary.total = response.data.total
summary.active = allPersons.filter((p: Person) => p.status === 'active').length
summary.inactive = allPersons.filter((p: Person) => p.status === 'inactive').length
summary.synced = allPersons.filter((p: Person) => p.is_synced).length
summary.local = allPersons.filter((p: Person) => !p.is_synced).length
summary.unassigned = allPersons.filter((p: Person) => !p.main_department && !p.department).length
}
} catch {
// 汇总失败不影响主流程
}
}
const resetNewForm = () => {
newPerson.nickname = ''
newPerson.real_name = ''
@ -363,6 +531,7 @@ const handleCreatePerson = async () => {
if (!canCreate.value) return
try {
createLoading.value = true
const personName = newPerson.nickname.trim()
await createPerson({
nickname: newPerson.nickname,
real_name: newPerson.real_name,
@ -374,11 +543,11 @@ const handleCreatePerson = async () => {
status: newPerson.status,
remarks: newPerson.remarks || undefined
})
alert('使用者创建成功')
showNotification('success', '创建成功', `使用者 "${personName}" 已创建`)
closeCreateModal()
fetchPersons()
await Promise.all([fetchPersons(), fetchSummary()])
} catch (error: any) {
alert(error?.message || '创建使用者失败')
showNotification('error', '创建失败', error?.message || '创建使用者失败')
} finally {
createLoading.value = false
}
@ -406,6 +575,7 @@ const submitEdit = async () => {
if (!editingPerson.value) return
try {
updateLoading.value = true
const personName = editDraft.nickname.trim()
await updatePerson(editingPerson.value.id, {
nickname: editDraft.nickname,
real_name: editDraft.real_name,
@ -417,25 +587,53 @@ const submitEdit = async () => {
status: editDraft.status,
remarks: editDraft.remarks || undefined
})
alert('使用者信息已更新')
showNotification('success', '更新成功', `使用者 "${personName}" 信息已更新`)
closeEdit()
fetchPersons()
await Promise.all([fetchPersons(), fetchSummary()])
} catch (error: any) {
alert(error?.message || '更新失败')
showNotification('error', '更新失败', error?.message || '更新使用者信息失败')
} finally {
updateLoading.value = false
}
}
const toggleStatus = async (person: Person) => {
const confirmToggleStatus = (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 || '更新状态失败')
}
const actionText = targetStatus === 'active' ? '启用' : '禁用'
openConfirmDialog(
`${actionText}使用者`,
`确定要${actionText}使用者 "${person.nickname}" (${person.real_name}) 吗?`,
actionText,
targetStatus === 'active' ? 'primary' : 'danger',
async () => {
try {
await updatePersonStatus(person.id, targetStatus)
showNotification('success', `${actionText}成功`, `使用者 "${person.nickname}" 已${actionText}`)
await Promise.all([fetchPersons(), fetchSummary()])
} catch (error: any) {
showNotification('error', `${actionText}失败`, error?.message || `无法${actionText}该使用者`)
}
}
)
}
const confirmDelete = (person: Person) => {
openConfirmDialog(
'删除使用者',
`确定要删除使用者 "${person.nickname}" (${person.real_name}) 吗?此操作不可恢复。`,
'删除',
'danger',
async () => {
try {
await deletePerson(person.id)
showNotification('success', '删除成功', `使用者 "${person.nickname}" 已删除`)
await Promise.all([fetchPersons(), fetchSummary()])
} catch (error: any) {
showNotification('error', '删除失败', error?.message || '无法删除该使用者')
}
}
)
}
const handlePrevPage = () => {
@ -450,21 +648,6 @@ const handleNextPage = () => {
fetchPersons()
}
const handleDeletePerson = async (person: Person) => {
if (!confirm(`确定要删除使用者 "${person.nickname}" 吗?此操作不可恢复。`)) return
try {
await deletePerson(person.id)
alert('删除成功')
fetchPersons()
} catch (error: any) {
alert(error?.message || '删除失败')
}
}
onMounted(() => {
fetchPersons()
})
const downloadTemplate = () => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
window.open(`${baseUrl}/export/person-template`, '_blank')
@ -513,7 +696,7 @@ const handleFileImport = async (event: Event) => {
try {
const rows = await parseFileContent(file)
if (rows.length < 2) {
alert('文件内容为空或格式不正确')
showNotification('error', '导入失败', '文件内容为空或格式不正确')
input.value = ''
return
}
@ -532,7 +715,7 @@ const handleFileImport = async (event: Event) => {
.filter((p) => p.nickname && p.real_name)
if (personList.length === 0) {
alert('没有有效的导入数据')
showNotification('error', '导入失败', '没有有效的导入数据')
input.value = ''
return
}
@ -540,18 +723,23 @@ const handleFileImport = async (event: Event) => {
const response = await batchImportPersons(personList)
const apiData = response.data
if (apiData) {
let msg = `导入完成:成功 ${apiData.success} 条,失败 ${apiData.failed} 条`
let msg = `成功 ${apiData.success} 条,失败 ${apiData.failed} 条`
if (apiData.errors?.length > 0) {
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} 条错误`
msg += '。错误详情:' + apiData.errors.slice(0, 3).map((e: { row: number; message: string }) => `第${e.row}行: ${e.message}`).join(';')
if (apiData.errors.length > 3) msg += ` 等共 ${apiData.errors.length} 条错误`
}
alert(msg)
fetchPersons()
showNotification(apiData.failed === 0 ? 'success' : 'error', '导入完成', msg)
await Promise.all([fetchPersons(), fetchSummary()])
}
} catch (error: any) {
alert(error?.message || '导入失败')
showNotification('error', '导入失败', error?.message || '导入过程中发生错误')
}
input.value = ''
}
onMounted(() => {
fetchPersons()
fetchSummary()
})
</script>

View File

@ -3,7 +3,7 @@
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<div class="filter-bar__inputs">
<input
v-model.trim="filters.keyword"
class="input"
@ -11,8 +11,6 @@
placeholder="输入地址名称 / 编码 / 备注关键字"
@keyup.enter="fetchLocations"
/>
</div>
<div class="flex flex-wrap gap-2 sm:gap-3">
<select v-model="filters.status" class="select">
<option value="">全部状态</option>
<option value="active">启用</option>

View File

@ -28,29 +28,34 @@
<!-- 筛选 -->
<section class="card">
<div class="filter-bar">
<div class="field-stack">
<label class="field-label">资产类型</label>
<select v-model="filters.equipment_type" class="select" @change="handleSearch">
<option value="">全部类型</option>
<option v-for="assetType in assetTypes" :key="assetType.id" :value="assetType.name">
{{ assetType.name }}
</option>
</select>
<div class="filter-bar__inputs">
<div class="field-stack">
<label class="field-label">资产类型</label>
<select v-model="filters.equipment_type" class="select" @change="handleSearch">
<option value="">全部类型</option>
<option v-for="assetType in assetTypes" :key="assetType.id" :value="assetType.name">
{{ assetType.name }}
</option>
</select>
</div>
<div class="field-stack">
<label class="field-label">开始日期</label>
<input v-model="filters.start_date" type="date" class="input" />
</div>
<div class="field-stack">
<label class="field-label">结束日期</label>
<input v-model="filters.end_date" type="date" class="input" />
</div>
<div class="field-stack">
<label class="field-label">操作</label>
<div class="flex gap-2">
<button class="btn-secondary text-xs sm:text-sm" @click="handleReset">重置</button>
<button class="btn-primary text-xs sm:text-sm" :disabled="loading" @click="handleSearch">
{{ loading ? '统计中...' : '筛选' }}
</button>
</div>
</div>
</div>
<div class="field-stack">
<label class="field-label">开始日期</label>
<input v-model="filters.start_date" type="date" class="input" />
</div>
<div class="field-stack">
<label class="field-label">结束日期</label>
<input v-model="filters.end_date" type="date" class="input" />
</div>
</div>
<div class="flex flex-wrap gap-3 mt-4">
<button class="btn-secondary text-sm" @click="handleReset">重置</button>
<button class="btn-primary text-sm" :disabled="loading" @click="handleSearch">
{{ loading ? '统计中...' : '应用筛选' }}
</button>
</div>
</section>

View File

@ -244,11 +244,15 @@
- 文字行间距:`mt-1`、`mt-2`(4px、8px)
### 4.3 响应式断点
- 移动端:`< 640px`
- 平板:`640px - 1024px`
- 桌面:`>= 1024px`
**移动端适配原则:**
| 断点 | 范围 | 用途 |
|------|------|------|
| `md` | >= 768px | 列表切换:卡片 ↔ 表格 |
| `lg` | >= 1024px | 页面布局调整 |
**说明**:项目代码统一使用 `md` (768px) 作为移动端/桌面端列表切换断点。
**移动端适配原则(< 768px):**
- 卡片内边距减小到 `12px`
- 圆角减小到 `8px`
- 表格转为卡片列表

View File

@ -1,20 +1,20 @@
# 固定资产管理系统 - 前端视觉重构台账
**重构日期**: 2026-03-17
**重构范围**: 全站前端视觉统一
**重构范围**: 全站前端视觉统一(24个页面)+ 移动端全面优化
**版本**: v2.0
---
## 一、重构概述
本次重构将项目前端视觉全面统一,建立了严格的视觉规范体系,确保所有页面风格一致、排版协调。
本次重构将项目前端视觉全面统一,建立了严格的视觉规范体系,确保所有页面风格一致、排版协调,并全面适配移动端。
### 核心目标
1. **统一页面结构** - 所有页面遵循相同的布局层次
2. **统一组件样式** - 按钮、输入框、卡片等组件完全一致
3. **统一间距系统** - 严格执行 4px 基准的间距规范
4. **统一响应式策略** - 移动端/桌面端切换时机一致
3. **统一响应式策略** - 移动端/桌面端切换时机一致
4. **优化空间利用** - 筛选栏、统计卡片等布局紧凑高效
---
@ -30,341 +30,144 @@
|---------|------|---------|
| `frontend/src/layouts/MainLayout.vue` | 侧边栏+顶部导航布局 | 结构调整 |
### 页面组件(18个)
| 模块 | 文件路径 | 说明 | 变更类型 |
|------|---------|------|---------|
| 登录 | `frontend/src/views/Login.vue` | 登录页 | 样式统一 |
| 首页 | `frontend/src/views/dashboard/Dashboard.vue` | 仪表盘 | 结构重构 |
| 资产列表 | `frontend/src/views/equipment/EquipmentList.vue` | 资产管理主页面 | 结构重构 |
| 资产表单 | `frontend/src/views/equipment/EquipmentForm.vue` | 新增/编辑资产 | 结构重构 |
| 借用列表 | `frontend/src/views/borrow/BorrowList.vue` | 借用记录 | 结构重构 |
| 扫码借出 | `frontend/src/views/borrow/BorrowScan.vue` | 扫码出库页面 | 样式统一 |
| 扫码归还 | `frontend/src/views/borrow/ReturnScan.vue` | 扫码入库页面 | 样式统一 |
| 统计分析 | `frontend/src/views/statistics/Statistics.vue` | 数据统计 | 结构重构 |
| 站内消息 | `frontend/src/views/notifications/NotificationCenter.vue` | 消息中心 | 结构重构 |
| 设置主框架 | `frontend/src/views/settings/Settings.vue` | 系统设置框架 | 结构重构 |
| 站点设置 | `frontend/src/views/settings/components/SiteSettings.vue` | 站点配置 | 样式统一 |
| 用户管理 | `frontend/src/views/settings/components/UserManagement.vue` | 用户管理 | 结构重构 |
| 人员管理 | `frontend/src/views/settings/components/PersonManagement.vue` | 人员管理 | 结构重构 |
| 部门管理 | `frontend/src/views/settings/components/DepartmentManagement.vue` | 部门管理 | 结构重构 |
| 资产类型 | `frontend/src/views/settings/components/AssetTypeManagement.vue` | 类型管理 | 样式统一 |
| 地址管理 | `frontend/src/views/settings/components/StorageLocationManagement.vue` | 地址管理 | 样式统一 |
| 维修管理 | `frontend/src/views/settings/components/MaintenanceManagement.vue` | 维修管理 | 结构重构 |
| 报废管理 | `frontend/src/views/settings/components/DisposalManagement.vue` | 报废管理 | 结构重构 |
| 操作日志 | `frontend/src/views/settings/components/OperationLogList.vue` | 日志查询 | 结构重构 |
| 组织同步 | `frontend/src/views/settings/components/HrSyncManagement.vue` | HR同步 | 样式统一 |
### 页面组件(24个)
| 模块 | 文件路径 | 变更类型 |
|------|---------|---------|
| 登录 | `views/Login.vue` | 样式统一 |
| 首页 | `views/dashboard/Dashboard.vue` | 结构重构 |
| 资产列表 | `views/equipment/EquipmentList.vue` | 结构重构 |
| 资产表单 | `views/equipment/EquipmentForm.vue` | 结构重构 |
| 借用列表 | `views/borrow/BorrowList.vue` | 结构重构 |
| 扫码借出 | `views/borrow/BorrowScan.vue` | 样式统一 |
| 扫码归还 | `views/borrow/ReturnScan.vue` | 样式统一 |
| 统计分析 | `views/statistics/Statistics.vue` | 结构重构 |
| 站内消息 | `views/notifications/NotificationCenter.vue` | 结构重构 |
| 设置主框架 | `views/settings/Settings.vue` | 结构重构 |
| 站点设置 | `views/settings/components/SiteSettings.vue` | 样式统一 |
| 用户管理 | `views/settings/components/UserManagement.vue` | 结构重构 |
| 人员管理 | `views/settings/components/PersonManagement.vue` | 结构重构 |
| 部门管理 | `views/settings/components/DepartmentManagement.vue` | 结构重构 |
| 资产类型 | `views/settings/components/AssetTypeManagement.vue` | 样式统一 |
| 地址管理 | `views/settings/components/StorageLocationManagement.vue` | 样式统一 |
| 维修管理 | `views/settings/components/MaintenanceManagement.vue` | 结构重构 |
| 报废管理 | `views/settings/components/DisposalManagement.vue` | 结构重构 |
| 操作日志 | `views/settings/components/OperationLogList.vue` | 结构重构 |
| 组织同步 | `views/settings/components/HrSyncManagement.vue` | 样式统一 |
| **盘点模块** ||
| 盘点列表 | `views/inventory/InventoryList.vue` | 结构重构 |
| 新建盘点 | `views/inventory/InventoryCreate.vue` | 结构重构 |
| 盘点详情 | `views/inventory/InventoryDetail.vue` | 结构重构 |
| 手机盘点 | `views/inventory/PublicInventoryTask.vue` | 结构重构 |
**总计**: 20 个文件
**总计**: 24 个页面文件
---
## 三、视觉规范体系
## 三、页面结构模板
### 3.1 颜色系统
```css
/* 背景色 */
--bg-primary: #0d0d0d /* 页面主背景 - 纯黑 */
--bg-secondary: #141414 /* 卡片背景 - 深灰 */
--bg-tertiary: #1a1a1a /* 表头背景 */
--bg-elevated: #242424 /* 输入框背景 */
--bg-hover: #2a2a2a /* 悬停状态 */
/* 文字色 */
--text-primary: #ffffff /* 主文字 - 纯白 */
--text-secondary: #a1a1a1 /* 次要文字 - 灰白 */
--text-tertiary: #6e6e6e /* 辅助文字 - 深灰 */
/* 边框 */
--border-color: #2a2a2a /* 默认边框 */
--border-hover: #3a3a3a /* 悬停边框 */
/* 强调色 */
--accent-primary: #10a37f /* 主按钮、链接 - 青绿 */
--accent-primary-hover: #0d8c6d /* 按钮悬停 */
/* 状态色 */
--status-success: #10a37f /* 成功、可用 */
--status-warning: #f59e0b /* 警告、借出 */
--status-error: #ef4444 /* 错误、删除 */
--status-neutral: #6b7280 /* 中性、禁用 */
```
### 3.2 间距系统(严格执行)
| 变量 | 值 | 使用场景 |
|------|-----|---------|
| `--space-1` | 4px | 微调间距 |
| `--space-2` | 8px | 小间距 |
| `--space-3` | 12px | 按钮组间距、列表项间距 |
| `--space-4` | 16px | 卡片内间距、表单字段间距 |
| `--space-5` | 20px | 卡片 padding |
| `--space-6` | 24px | 页面级间距 |
| `--space-8` | 32px | 大区块间距 |
**强制规范**:
- 页面级间距统一使用 `space-y-6` (24px)
- 卡片内元素间距统一使用 `gap-4` (16px)
- 表单字段间距使用 `field-stack` (6px)
### 3.3 圆角系统
| 变量 | 值 | 使用场景 |
|------|-----|---------|
| `--radius-sm` | 6px | 小按钮 |
| `--radius-md` | 8px | 按钮、输入框 |
| `--radius-lg` | 12px | 卡片、面板 |
| `--radius-xl` | 16px | 弹窗 |
| `--radius-full` | 9999px | 标签、圆形按钮 |
### 3.4 字体规范
| 级别 | 类名 | 大小 | 用途 |
|------|------|------|------|
| 页面标题 | `.page-title` | 24px | 页面主标题 |
| 区块标题 | `.section-title` | 16px | 卡片/列表标题 |
| 正文 | `.input` 等 | 14px | 普通文字 |
| 辅助文字 | `.mobile-card__label` | 11px | 标签、时间戳 |
---
## 四、页面结构规范
### 4.1 标准列表页结构
### 3.1 标准列表页结构
```vue
<template>
<div class="space-y-6">
<!-- 1. 页面头部 -->
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">英文标签</p>
<h2 class="page-title">页面标题</h2>
<div class="mt-5 flex flex-wrap gap-3">
<!-- 主要操作按钮 -->
<button class="btn-primary text-sm">主要操作</button>
</div>
</div>
<div class="page-meta-grid">
<!-- 3个统计卡片 -->
<div class="page-meta">...</div>
<div class="page-meta">...</div>
<div class="page-meta">...</div>
</div>
</section>
<!-- 2. 筛选区域 -->
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="filter-bar__inputs">
<!-- 筛选输入框 -->
<div class="field-stack">
<label class="field-label">标签</label>
<input class="input" />
</div>
<!-- 筛选输入框和按钮放在同一行 -->
<input class="input" />
<select class="select">...</select>
</div>
<div class="filter-bar__actions">
<!-- 筛选按钮 -->
<button class="btn-secondary text-sm">重置</button>
<button class="btn-primary text-sm">查询</button>
</div>
</div>
</section>
<!-- 3. 数据列表 -->
<!-- 数据列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">列表标题</h3>
<span class="badge badge-neutral">共 XX 条</span>
</div>
<!-- 移动端:卡片列表 -->
<!-- 移动端(<768px):卡片列表 -->
<div class="mobile-card-list md:hidden">
<article class="mobile-card">
<div class="mobile-card__row">...</div>
<div class="mobile-card__meta">...</div>
</article>
<article class="mobile-card">...</article>
</div>
<!-- 桌面端:表格 -->
<!-- 桌面端(>=768px):表格 -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table min-w-[900px]">
...
</table>
</div>
</div>
<!-- 分页 -->
<div class="pagination-bar">
<p class="text-sm text-text-secondary">共 XX 条记录</p>
<div class="flex items-center gap-2">
<button class="btn-secondary text-sm">上一页</button>
<span class="text-sm text-text-primary">1 / 10</span>
<button class="btn-secondary text-sm">下一页</button>
</div>
<table class="data-table">...</table>
</div>
</section>
</div>
</template>
```
### 4.2 标准表单页结构
### 3.2 双统计卡片布局(2个卡片时使用)
```vue
<template>
<div class="space-y-6 pb-24 md:pb-0">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Field Entry</p>
<h2 class="page-title">表单标题</h2>
</div>
<div class="flex gap-3">
<button class="btn-secondary text-sm">次要操作</button>
</div>
</section>
<!-- 不要:使用 page-meta-grid(适合3个卡片) -->
<div class="page-meta-grid">
<div class="page-meta">...</div>
<div class="page-meta">...</div>
</div>
<!-- 表单卡片 -->
<section class="card">
<h3 class="section-title mb-5">区块标题</h3>
<form @submit.prevent="handleSubmit" class="space-y-5">
<div class="field-grid">
<div class="field-stack">
<label class="field-label">字段名 *</label>
<input class="input" required />
</div>
<div class="field-stack lg:col-span-2">
<label class="field-label">长字段</label>
<textarea class="input" rows="3"></textarea>
</div>
</div>
<!-- 底部操作栏 -->
<div class="mobile-sticky-actions md:static">
<div class="flex flex-col gap-2 md:flex-row md:justify-end">
<button type="submit" class="btn-primary w-full md:w-auto">保存</button>
<button type="button" class="btn-secondary w-full md:w-auto">取消</button>
</div>
</div>
</form>
</section>
<!-- 要:使用 flex 布局 -->
<div class="flex gap-3">
<div class="page-meta" style="min-width: 120px;">
<p class="page-meta__label">标签1</p>
<p class="page-meta__value">值1</p>
</div>
</template>
```
### 4.3 标准设置页结构
```vue
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">英文标签</p>
<h2 class="page-title">设置标题</h2>
</div>
<div class="page-meta-grid">...</div>
</section>
<!-- 筛选/新增卡片 -->
<section class="card">
<div class="filter-bar">...</div>
</section>
<!-- 列表卡片 -->
<section class="card">
<div class="list-header">...</div>
<!-- 移动端卡片 / 桌面端表格 -->
<div class="pagination-bar">...</div>
</section>
<!-- 编辑弹窗 -->
<div v-if="editing" class="sheet-backdrop">
<div class="sheet-panel max-w-2xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">编辑</h3>
<div class="space-y-4">
<div class="field-stack">...</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="close">取消</button>
<button class="btn-primary" @click="save">保存</button>
</div>
</div>
</div>
<div class="page-meta" style="min-width: 120px;">
<p class="page-meta__label">标签2</p>
<p class="page-meta__value">值2</p>
</div>
</template>
</div>
```
---
## 五、组件类名速查表
### 5.1 按钮
## 四、组件类名速查表
### 按钮
```vue
<!-- 主要按钮 -->
<button class="btn-primary">确认</button>
<!-- 次要按钮 -->
<button class="btn-secondary">取消</button>
<!-- 危险按钮 -->
<button class="btn-danger">删除</button>
<!-- 图标按钮 -->
<button class="btn-primary">主要按钮</button>
<button class="btn-secondary">次要按钮</button>
<button class="btn-danger">危险按钮</button>
<button class="btn-icon"><Icon /></button>
<!-- 小尺寸按钮 -->
<button class="btn-primary text-sm">小按钮</button>
<!-- 移动端小尺寸 -->
<button class="btn-primary text-xs sm:text-sm">保存</button>
```
### 5.2 表单
### 表单
```vue
<!-- 字段组 -->
<div class="field-stack">
<label class="field-label">字段名</label>
<input class="input" />
</div>
<!-- 输入框 -->
<input class="input" type="text" placeholder="提示文字" />
<!-- 选择框 -->
<select class="select">
<option>选项</option>
</select>
<!-- 多行输入 -->
<textarea class="input" rows="3"></textarea>
<!-- 表单网格(2列) -->
<div class="field-grid">
<div class="field-stack">...</div>
<div class="field-stack">...</div>
</div>
<!-- 表单网格(跨2列) -->
<div class="field-stack lg:col-span-2">
<label class="field-label">长字段</label>
<textarea class="input" rows="3"></textarea>
<div class="field-stack">
<label class="field-label">字段名</label>
<input class="input" />
</div>
</div>
```
### 5.3 卡片与面板
### 卡片
```vue
<!-- 主卡片 -->
<section class="card">...</section>
<!-- 内层面板 -->
<div class="panel">...</div>
```
### 5.4 标签
### 标签
```vue
<span class="badge badge-success">成功</span>
<span class="badge badge-warning">警告</span>
@ -372,27 +175,7 @@
<span class="badge badge-neutral">中性</span>
```
### 5.5 表格
```vue
<div class="table-container">
<table class="data-table min-w-[900px]">
<thead>
<tr>
<th>列标题</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据</td>
</tr>
</tbody>
</table>
</div>
```
### 5.6 移动端卡片列表
### 移动端卡片列表
```vue
<div class="mobile-card-list md:hidden">
<article class="mobile-card">
@ -410,370 +193,30 @@
</div>
```
### 5.7 弹窗
```vue
<div class="sheet-backdrop">
<div class="sheet-panel max-w-2xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">标题</h3>
<div class="space-y-4">...</div>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary">取消</button>
<button class="btn-primary">确认</button>
</div>
</div>
</div>
```
### 5.8 空状态
```vue
<p class="empty-state">暂无数据</p>
```
---
## 六、响应式断点规范
## 五、移动端优化记录
### 6.1 断点定义
| 断点 | 范围 | 类名前缀 |
|------|------|---------|
| sm | >= 640px | `sm:` |
| md | >= 768px | `md:` |
| lg | >= 1024px | `lg:` |
| xl | >= 1280px | `xl:` |
### 6.2 列表切换策略
**统一使用 md 断点**:
```vue
<!-- 移动端显示,桌面端隐藏 -->
<div class="mobile-card-list md:hidden">...</div>
<!-- 移动端隐藏,桌面端显示 -->
<div class="hidden md:block">...</div>
```
### 6.3 网格响应式
```vue
<!-- 1列 -> 2列 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">...</div>
<!-- 1列 -> 2列 -> 3列 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">...</div>
<!-- 1列 -> 2列 -> 4列 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">...</div>
```
---
## 七、新增/修改的 CSS 类名
### 7.1 页面结构
```css
.page-header /* 页面头部容器 */
.page-kicker /* 英文标签 */
.page-title /* 页面标题 */
.page-meta-grid /* 统计卡片网格 */
.page-meta /* 统计卡片 */
.page-meta__label /* 统计标签 */
.page-meta__value /* 统计数值 */
.page-meta__subvalue /* 统计副标题 */
.list-header /* 列表头部 */
.section-title /* 区块标题 */
```
### 7.2 筛选区域
```css
.filter-bar /* 筛选栏容器 */
.filter-bar__inputs /* 输入区 */
.filter-bar__actions /* 按钮区 */
```
### 7.3 表单系统
```css
.field-grid /* 表单网格(响应式2列) */
.field-grid--3 /* 表单网格(响应式3列) */
.field-stack /* 字段组(标签+输入) */
.field-label /* 字段标签 */
```
### 7.4 表格系统
```css
.table-container /* 表格容器(带边框和圆角) */
.data-table /* 数据表格 */
```
### 7.5 移动端卡片
```css
.mobile-card-list /* 卡片列表容器 */
.mobile-card /* 单个卡片 */
.mobile-card__row /* 卡片行(标题+状态) */
.mobile-card__meta /* 卡片元信息网格 */
.mobile-card__label /* 标签 */
.mobile-card__value /* 值 */
```
### 7.6 其他
```css
.empty-state /* 空状态 */
.pagination-bar /* 分页栏 */
.mobile-sticky-actions /* 移动端底部固定操作栏 */
.sheet-backdrop /* 弹窗遮罩 */
.sheet-panel /* 弹窗面板 */
```
---
## 八、开发注意事项
### 8.1 必须遵守的规范
1. **页面间距**: 所有页面根容器必须使用 `space-y-6`
2. **卡片内边距**: 所有 `card` 内部使用 `p-5`(已由 card 类自带)
3. **表单标签**: 必须使用 `field-label`,禁止自定义样式
4. **按钮顺序**: 主要操作在左,次要操作在右(弹窗内相反)
5. **列表头部**: 必须包含 `list-header` + `section-title`
### 8.2 禁止使用的类名
已废弃的类名(虽然 style.css 保留了兼容,但新代码不应使用):
- `btn-apple-*` 系列
- `input-apple`
- `card-apple`
- `text-apple-*`
- `text-system-label-*`
- `bg-system-gray-*`
- `border-system-gray-*`
- `surface-note`
- `page-subtitle`
- `section-subtitle`
### 8.3 推荐的间距
- 页面级: `space-y-6` (24px)
- 卡片内: `gap-4` (16px)
- 按钮组: `gap-3` (12px)
- 列表项: `mobile-card-list` 自带 12px gap
**禁止使用的间距**:
- `gap-5` (20px) - 非标准间距
- `mt-5` / `mb-5` - 使用 `mt-4` (16px) 或 `mt-6` (24px)
### 8.4 断点使用
- 列表切换统一使用 `md` 断点
- 网格响应式使用 `sm:` 和 `lg:`
- 不要混用 `md` 和 `lg` 进行列表切换
---
## 九、示例页面参考
### 最标准的列表页
`frontend/src/views/equipment/EquipmentList.vue`
### 最标准的表单页
`frontend/src/views/equipment/EquipmentForm.vue`
### 最标准的设置页
`frontend/src/views/settings/components/UserManagement.vue`
---
## 十、构建验证
```bash
cd frontend
npm run build
```
**预期结果**:
- ✓ vue-tsc -b 无错误
- ✓ vite build 成功
- ✓ 所有 chunk 生成完成
---
## 附录:颜色使用速查
| 用途 | 类名 |
|------|------|
| 主文字 | `text-text-primary` |
| 次要文字 | `text-text-secondary` |
| 辅助文字 | `text-text-tertiary` |
| 强调色 | `text-accent` |
| 成功状态 | `text-status-success` |
| 警告状态 | `text-status-warning` |
| 错误状态 | `text-status-error` |
| 页面背景 | `bg-bg-primary` |
| 卡片背景 | `bg-bg-secondary` |
| 输入框背景 | `bg-bg-elevated` |
---
---
## 十一、移动端优化(新增)
**优化日期**: 2026-03-17
**优化范围**: 全站移动端体验增强
### 11.1 优化内容
#### 样式系统增强 (`style.css`)
新增移动端全面优化样式(在文件末尾添加):
```css
/* 移动端按钮尺寸优化 - 修复按钮过大问题 */
@media (max-width: 767px) {
.btn-primary, .btn-secondary, .btn-danger {
min-height: 40px;
padding: 0.5rem 0.875rem;
font-size: 13px;
}
}
/* 移动端徽章防止压缩 - 修复胶囊变圆问题 */
@media (max-width: 767px) {
.badge {
flex-shrink: 0;
white-space: nowrap;
padding: 0.25rem 0.5rem;
font-size: 10px;
}
}
/* 移动端卡片紧凑模式 - 修复卡片过大问题 */
@media (max-width: 767px) {
.card { padding: var(--space-3); border-radius: var(--radius-md); }
.panel { padding: var(--space-3); }
.mobile-card { padding: var(--space-3); }
}
/* 移动端页面头部优化 */
@media (max-width: 767px) {
.page-header { padding: var(--space-3); gap: var(--space-3); }
.page-title { font-size: 20px; }
.page-kicker { font-size: 10px; }
.page-meta { padding: var(--space-3); }
.page-meta__value { font-size: 16px; }
}
/* iOS 防缩放 */
@media (max-width: 767px) {
.input, .select, textarea.input {
font-size: 16px;
min-height: 48px;
}
}
/* 安全区域适配 */
@supports (padding: max(0px)) {
.mobile-sticky-actions {
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
}
}
```
#### 页面移动端适配
### 5.1 页面移动端适配详情
| 页面 | 优化内容 |
|------|---------|
| `Dashboard.vue` | 最近借用记录添加 `mobile-card-list`;统计卡片使用 `grid-cols-2` 并缩小字体 |
| `Statistics.vue` | Top 10 高价值资产添加 `mobile-card-list` 移动端卡片列表 |
| `EquipmentList.vue` | 移动端卡片按钮使用 `text-xs` 并简化文字 |
| `NotificationCenter.vue` | 按钮使用 `text-xs sm:text-sm`,"全部标记已读"简化为"全标已读" |
| `PersonManagement.vue` | 按钮添加 `text-xs sm:text-sm`,文字简化 |
| `UserManagement.vue` | 按钮添加 `text-xs sm:text-sm` |
| `AssetTypeManagement.vue` | 按钮添加 `text-xs sm:text-sm` |
| `StorageLocationManagement.vue` | 按钮添加 `text-xs sm:text-sm` |
| `DepartmentManagement.vue` | 按钮添加 `text-xs sm:text-sm`,文字简化 |
| `MaintenanceManagement.vue` | 按钮添加 `text-xs sm:text-sm`,"完成维修/取消维修"简化为"完成/取消" |
| `DisposalManagement.vue` | 按钮添加 `text-xs sm:text-sm`,"通过审批/驳回申请"简化为"通过/驳回" |
| `OperationLogList.vue` | 按钮添加 `text-xs sm:text-sm` |
| `HrSyncManagement.vue` | 按钮添加 `text-xs sm:text-sm`,"刷新预览/立即同步"简化为"刷新/同步" |
| `SiteSettings.vue` | 按钮添加 `text-xs sm:text-sm` |
| `Dashboard.vue` | 最近借用记录添加 `mobile-card-list`;统计卡片使用 `grid-cols-2` |
| `Statistics.vue` | 高价值资产添加 `mobile-card-list`;筛选区域使用 `filter-bar__inputs` 网格布局 |
| `EquipmentList.vue` | 筛选按钮移入 `filter-bar__inputs`;移动端按钮文字简化 |
| `NotificationCenter.vue` | 统计卡片改为 `flex` 布局;按钮文字简化 |
| `Settings.vue` | 统计卡片改为 `flex` 布局 |
| `BorrowList.vue` | 筛选栏改为 `filter-bar__inputs` 网格布局 |
| `AssetTypeManagement.vue` | 筛选栏改为 `filter-bar__inputs` 网格布局 |
| `StorageLocationManagement.vue` | 筛选栏改为 `filter-bar__inputs` 网格布局 |
| `PersonManagement.vue` | 筛选栏改为 `filter-bar__inputs` 网格布局 |
| `DepartmentManagement.vue` | 筛选栏改为 `filter-bar__inputs` 网格布局;下拉选项文字简化 |
| 其他设置页面 | 按钮添加 `text-xs sm:text-sm`,文字简化 |
#### 移动端列表结构模板
### 5.2 按钮文字简化规范
```vue
<!-- 移动端卡片列表 (md:hidden) -->
<div class="mobile-card-list md:hidden">
<article v-for="item in list" :key="item.id" class="mobile-card">
<div class="mobile-card__row">
<p class="text-sm font-semibold text-text-primary">{{ item.name }}</p>
<span class="badge badge-success">状态</span>
</div>
<div class="mobile-card__meta mt-3">
<div>
<p class="mobile-card__label">标签</p>
<p class="mobile-card__value">{{ item.value }}</p>
</div>
</div>
</article>
<div v-if="list.length === 0" class="empty-state">暂无数据</div>
</div>
<!-- 桌面端表格 (hidden md:block) -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table">...</table>
</div>
</div>
```
### 11.2 移动端组件清单
| 组件类名 | 用途 | 移动端特性 |
|---------|------|-----------|
| `.mobile-card-list` | 卡片列表容器 | 垂直布局,12px 间距 |
| `.mobile-card` | 单个卡片 | 触摸反馈、点击缩放效果 |
| `.mobile-card__row` | 卡片标题行 | 标题+状态 横向排列 |
| `.mobile-card__meta` | 卡片元信息 | 2列网格布局 |
| `.mobile-sticky-actions` | 底部固定操作栏 | 移动端 fixed,桌面端 static |
| `.touch-option-list` | 触摸选项列表 | 大按钮,易于触摸 |
| `.touch-option` | 触摸选项 | 点击反馈效果 |
### 11.3 响应式断点策略
**统一使用 `md` (768px) 作为列表切换断点:**
```vue
<!-- 移动端显示,桌面端隐藏 -->
<div class="mobile-card-list md:hidden">...</div>
<!-- 移动端隐藏,桌面端显示 -->
<div class="hidden md:block">...</div>
```
### 11.4 移动端按钮规范
**尺寸规范:**
```css
/* 移动端默认按钮 */
min-height: 40px;
padding: 0.5rem 0.875rem;
font-size: 13px;
/* 移动端小按钮 */
class="text-xs" /* 12px */
```
**文字简化原则:**
| 桌面端文字 | 移动端文字 |
|-----------|-----------|
| 桌面端 | 移动端 |
|-------|--------|
| 全部标记已读 | 全标已读 |
| 刷新预览 | 刷新 |
| 立即同步 | 同步 |
@ -787,70 +230,80 @@ class="text-xs" /* 12px */
| 查看配件 N | 配件(N) |
| 收起配件 | 收起 |
| 打印标签 | 标签 |
| 更多筛选 | 更多 |
| 更多操作 | 操作 |
| 应用筛选 | 筛选 |
**Vue 模板示例:**
```vue
<button class="btn-primary text-xs sm:text-sm">保存</button>
<button class="btn-secondary text-xs sm:text-sm">取消</button>
```
### 5.3 移动端样式覆盖(style.css 末尾添加)
### 11.5 移动端表单优化
1. **输入框字体大小**: 统一使用 `16px`,防止 iOS 自动缩放
2. **触摸目标**: 按钮最小高度 `40px` (移动端)
3. **底部固定操作栏**: 长表单使用 `mobile-sticky-actions`
```vue
<template>
<div class="space-y-6 pb-24 md:pb-0">
<!-- 表单内容 -->
<!-- 底部操作栏 -->
<div class="mobile-sticky-actions md:static">
<button type="submit" class="btn-primary w-full">保存</button>
</div>
</div>
</template>
```
### 11.6 移动端徽章(Badge)规范
**防止压缩成圆:**
```css
/* 按钮尺寸优化 */
@media (max-width: 767px) {
.btn-primary, .btn-secondary, .btn-danger {
min-height: 40px;
padding: 0.5rem 0.875rem;
font-size: 13px;
}
}
/* 徽章防止压缩成圆 */
@media (max-width: 767px) {
.badge {
flex-shrink: 0; /* 防止被压缩 */
white-space: nowrap; /* 防止文字换行 */
flex-shrink: 0;
white-space: nowrap;
padding: 0.25rem 0.5rem;
font-size: 10px;
}
}
```
**使用建议:**
- 徽章文字尽量简短(2-4个字符)
- 使用 `flex-shrink-0` 防止在 flex 容器中被压缩
- 使用 `white-space: nowrap` 防止文字换行导致高度变化
### 11.7 移动端手势支持
```css
/* 滑动容器 */
.swipe-container {
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
/* 卡片紧凑模式 */
@media (max-width: 767px) {
.card { padding: 12px; }
.page-title { font-size: 20px; }
.page-meta { padding: 12px; }
}
.swipe-item {
scroll-snap-align: start;
flex-shrink: 0;
/* iOS 防缩放 */
@media (max-width: 767px) {
.input, .select, textarea.input {
font-size: 16px;
}
}
/* 筛选栏网格布局 */
.filter-bar__inputs {
display: grid;
gap: 12px;
flex: 1;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
```
---
## 六、参考文档
- **视觉规范**: `前端视觉规范.md` - 颜色、间距、字体等设计令牌
- **示例页面**:
- 列表页: `views/equipment/EquipmentList.vue`
- 表单页: `views/equipment/EquipmentForm.vue`
- 设置页: `views/settings/components/UserManagement.vue`
---
### 断点说明
本项目实际代码使用 `md` (768px) 作为列表切换断点:
| 断点 | 范围 | 用途 |
|------|------|------|
| `md` | >= 768px | 列表切换:卡片 ↔ 表格 |
| `lg` | >= 1024px | 页面布局调整 |
**注意**:主视觉规范文档使用 640px 作为移动端分界,但代码实现统一使用 768px (`md`) 进行列表切换。
---
**文档版本**: v2.0
**最后更新**: 2026-03-17
**维护者**: Claude