完善组织同步盘点与资产迁移链路
This commit is contained in:
parent
c3f8eb6f6f
commit
ceacc2f98d
15
.gitignore
vendored
15
.gitignore
vendored
@ -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
|
||||
{
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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;
|
||||
@ -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())
|
||||
|
||||
326
backend/scripts/backfillImportedEquipmentLocations.js
Normal file
326
backend/scripts/backfillImportedEquipmentLocations.js
Normal 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)
|
||||
}
|
||||
439
backend/scripts/exportAdminManualReviewWorkbook.js
Normal file
439
backend/scripts/exportAdminManualReviewWorkbook.js
Normal 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)
|
||||
}
|
||||
771
backend/scripts/importConfirmedEquipmentToLive.js
Normal file
771
backend/scripts/importConfirmedEquipmentToLive.js
Normal 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)
|
||||
}
|
||||
133
backend/scripts/locationNormalization.js
Normal file
133
backend/scripts/locationNormalization.js
Normal 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
|
||||
}
|
||||
1200
backend/scripts/prepareEquipmentMigrationDryRun.js
Normal file
1200
backend/scripts/prepareEquipmentMigrationDryRun.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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, '目标部门不存在或已停用')
|
||||
}
|
||||
|
||||
|
||||
@ -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' } })
|
||||
])
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' }
|
||||
})
|
||||
|
||||
133
backend/src/controllers/inventoryController.ts
Normal file
133
backend/src/controllers/inventoryController.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
189
backend/src/controllers/publicInventoryController.ts
Normal file
189
backend/src/controllers/publicInventoryController.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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)
|
||||
|
||||
25
backend/src/routes/inventory.ts
Normal file
25
backend/src/routes/inventory.ts
Normal 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
|
||||
22
backend/src/routes/publicInventory.ts
Normal file
22
backend/src/routes/publicInventory.ts
Normal 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
1127
backend/src/services/inventoryService.ts
Normal file
1127
backend/src/services/inventoryService.ts
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/src/utils/organization.ts
Normal file
22
backend/src/utils/organization.ts
Normal 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
|
||||
})
|
||||
149
docs/2026-03-19_组织架构改造交接.md
Normal file
149
docs/2026-03-19_组织架构改造交接.md
Normal 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-*` 容器。
|
||||
- 不要碰摄影系统数据库。
|
||||
- 如需再次部署,只允许针对固定资产系统路径和容器。
|
||||
332
docs/2026-03-19_项目总交接.md
Normal file
332
docs/2026-03-19_项目总交接.md
Normal 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`,绝不能影响摄影借还系统。
|
||||
718
docs/2026-03-23_新对话完整交接.md
Normal file
718
docs/2026-03-23_新对话完整交接.md
Normal 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 接手后,第一件事就是把这两处脚本跑通、验清楚,再继续后面的闭环脚本。
|
||||
104
docs/2026-03-23_资产迁移推进记录.md
Normal file
104
docs/2026-03-23_资产迁移推进记录.md
Normal 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-*` 容器或其数据库
|
||||
572
docs/组织同步源表结构说明.md
Normal file
572
docs/组织同步源表结构说明.md
Normal 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. 如果还有成批错误人员,再继续反查它们是否又集中在新的异常部门线上
|
||||
1188
docs/资产盘点与组织同步联合方案.md
Normal file
1188
docs/资产盘点与组织同步联合方案.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
68
frontend/src/api/inventory.ts
Normal file
68
frontend/src/api/inventory.ts
Normal 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
|
||||
}
|
||||
105
frontend/src/api/publicInventory.ts
Normal file
105
frontend/src/api/publicInventory.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 '数据分析'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 || '固定资产管理系统')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
系统设置 > 使用者信息
|
||||
人员维护
|
||||
</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">
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
329
frontend/src/views/inventory/InventoryCreate.vue
Normal file
329
frontend/src/views/inventory/InventoryCreate.vue
Normal 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>
|
||||
736
frontend/src/views/inventory/InventoryDetail.vue
Normal file
736
frontend/src/views/inventory/InventoryDetail.vue
Normal 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>
|
||||
286
frontend/src/views/inventory/InventoryList.vue
Normal file
286
frontend/src/views/inventory/InventoryList.vue
Normal 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>
|
||||
794
frontend/src/views/inventory/PublicInventoryTask.vue
Normal file
794
frontend/src/views/inventory/PublicInventoryTask.vue
Normal 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>
|
||||
@ -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)))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
12
前端视觉规范.md
12
前端视觉规范.md
@ -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`
|
||||
- 表格转为卡片列表
|
||||
|
||||
841
前端视觉重构台账.md
841
前端视觉重构台账.md
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user