完成本轮功能与界面改造,备份可运行版本

This commit is contained in:
WuYongJie 2026-03-18 11:49:56 +08:00
parent 6d371f347b
commit c3f8eb6f6f
87 changed files with 13500 additions and 4431 deletions

View File

@ -14,6 +14,7 @@ SQLSERVER_ENCRYPT=false
SQLSERVER_TRUST_CERT=true
HR_SYNC_ENABLED=false
HR_SYNC_SCHEDULE=03:00
HR_SYNC_SCHEDULES=
HR_SYNC_ALLOWED_STATUS_VALUES=3
HR_SYNC_INACTIVE_STATUS_VALUES=5
HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流

2
.gitignore vendored
View File

@ -9,3 +9,5 @@ frontend/dist/
frontend/tsconfig.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts
frontend/public/branding/custom-logo.*
frontend/public/branding/custom-favicon.*

View File

@ -1,45 +1,48 @@
# Repository Guidelines
## 项目结构
仓库分为 `frontend/` 和 `backend/` 两部分。前端是 Vue 3 + Vite,主要代码在 `frontend/src/`:页面在 `src/views/`,通用组件在 `src/components/`,接口封装在 `src/api/`,状态管理在 `src/stores/`,路由在 `src/router/`。后端是 Express + TypeScript,代码在 `backend/src/`,按 `routes/`、`controllers/`、`middleware/`、`utils/` 分层。数据库模型统一在 `backend/prisma/schema.prisma`。部署相关文件在仓库根目录:`docker-compose.yml`、`Dockerfile`、`deploy.sh`。
## 1. 远程安全
## 构建、运行与开发命令
首次进入前后端目录分别执行 `npm install`。
- 服务器 `1.13.22.133` 有两套系统:
- 固定资产:`/opt/equipment-management`,容器前缀 `equipment-*`
- 摄影借还:`/opt/shebeiguanli`,容器前缀 `shebeiguanli-*`
- 任何远程操作只能针对固定资产系统。
- 不得进入 `/opt/shebeiguanli`,不得修改、停止、重建、删除 `shebeiguanli-*` 容器,也不得碰它的数据库、数据卷、配置。
- 部署、查日志、查库前,先确认目标路径、容器名、数据库名都属于固定资产系统。
- 远程自动化默认优先使用 `plink`。
- `cd frontend && npm run dev`:启动前端开发环境。
- `cd frontend && npm run build`:执行类型检查并打包前端。
- `cd backend && npm run dev`:用 `nodemon + ts-node` 启动后端。
- `cd backend && npm run build`:编译后端到 `dist/`。
- `cd backend && npm run prisma:generate`:Prisma 模型变更后重建客户端。
- `cd backend && npm run prisma:migrate`:生成并应用本地数据库迁移。
- `docker compose up --build`:同时启动 MySQL、后端、前端。
## 2. 协作方式
## 代码风格与命名
项目以 TypeScript 为主,现有代码统一使用 2 空格缩进。Vue 组件文件使用 PascalCase,如 `EquipmentForm.vue`;接口、store、工具模块使用语义化名称,如 `equipment.ts`、`storageLocation.ts`。新增功能时,尽量保持前端 API 名称、后端路由、控制器按同一业务域对应,方便排查问题。
- 本项目默认采用官方 `lead + 可复用子代理编排`。
- 用户只提目标、问题、期望效果,不需要做技术判断。
- 主代理默认承担 `lead`:负责理解需求、拆任务、判断是否启用子代理、整合结果、统一汇报。
- 本项目默认团队分工:
- `frontend-worker`:使用官方 `worker`,只负责前端文件。
- `backend-worker`:使用官方 `worker`,只负责后端 / 数据库相关文件。
- `review-explorer`:使用官方 `explorer`,只负责代码审查、找问题、提风险,不直接改代码。
- 子代理按职责复用,不必每次换一套;但仍遵守官方能力边界,不自定义内建类型。
- 不魔改为“常驻团队系统”;子代理只用官方支持的能力边界。
- 本项目内所有子代理默认统一使用 `gpt-5.4`。
- 所有 `worker` 类子代理固定使用 `gpt-5.4 + xhigh` 思考等级。
- `explorer` / 其他子代理思考等级按任务复杂度选择,但最低不得低于 `medium`。
- 只有涉及线上、真实数据、密钥账号、证书、付费资源、不可逆操作时才暂停确认。
## 测试与验证
当前仓库没有现成自动化测试脚本,因此每次改动后至少执行 `frontend` 和 `backend` 的 `npm run build`。涉及接口改动时,还应手动验证对应页面流程,并检查 `GET /api/health`。涉及 Prisma 或数据库结构调整时,额外执行 `npm run prisma:generate`,确认相关增删改查正常。若后续补测试,建议使用 `*.spec.ts` 命名。
## 3. 前端规则
## 提交与合并请求
当前 Git 历史很少,只有初始提交,没有强约束格式。建议提交信息使用简短中文,写清“改了什么”,例如:`后端:修复借用记录筛选条件`。Pull Request 需要说明变更范围、验证方式、是否影响数据库或部署;前端页面改动建议附截图,接口或表结构改动要明确标注兼容性影响。
- 前端改动前先读:`D:\AI Code\GuDingZiChan\前端视觉规范.md`
- 视觉方向固定为:极简、统一、深色主题。
- 优先使用:`btn-primary`、`btn-secondary`、`btn-icon`、`input`、`select`、`card`、`panel`、`page-header`、`page-meta`、`section-title`、`field-stack`、`field-grid`
- 禁止继续使用旧类:`btn-apple-*`、`input-apple`、`card-apple`、`text-apple-*`、`bg-apple-*`、`text-system-label-*`、`bg-system-gray-*`、`border-system-gray-*`、`rounded-apple`、`surface-note`、`page-subtitle`、`section-subtitle`
- 页面间距优先统一用 `gap-4` / `gap-6`,并同时检查桌面端和移动端效果。
## 配置与安全
不要提交真实密码、数据库连接串或生产环境地址。`DATABASE_URL`、会话密钥、认证模式等应通过环境变量管理。修改 `docker-compose.yml`、认证配置或对外端口时,先确认是否会影响本地联调和线上部署。
## 4. 改动与验证
## 远程操作规则
服务器 `1.13.22.133` 同时运行两套系统:
- 固定资产系统:`/opt/equipment-management`,对应 `equipment-*` 容器
- 摄影棚摄影器材借还系统:`/opt/shebeiguanli`,对应 `shebeiguanli-*` 容器
后续远程自动化默认统一优先使用 `plink`,避免依赖人工输入密码。对服务器的代码同步、部署、日志查看和数据库检查,只能操作固定资产系统这套资源,不能进入 `shebeiguanli` 目录,也不能重建、停止或修改 `shebeiguanli-*` 容器。
## Multi-Agent 团队分工
推荐使用 4 个角色,保持团队小而清晰。
- 主控 / 集成代理:负责拆任务、安排子代理、收敛方案、做最终验收;默认可读全仓库,但不要直接承担大批量业务改动。
- 前端业务代理:只负责 `frontend/src/views/`、`frontend/src/components/`、`frontend/src/api/`、`frontend/src/stores/`、`frontend/src/router/`,处理页面、交互、表单、状态和前端接口联调。
- 后端业务代理:只负责 `backend/src/routes/`、`backend/src/controllers/`,处理资产、借还、搜索、统计、人员等业务接口。
- 数据与系统代理:只负责 `backend/prisma/`、`backend/src/middleware/`、`backend/src/utils/`、认证配置、`docker-compose.yml`、`Dockerfile`、`deploy.sh`,处理数据库结构、权限、认证、部署和环境问题。
协作时尽量避免跨边界改动:前端代理不直接改 Prisma,后端业务代理不直接动 Docker,数据与系统代理不直接改页面。
- 前端优先改:`frontend/src/views/`、`frontend/src/components/`、`frontend/src/api/`、`frontend/src/stores/`、`frontend/src/router/`
- 后端优先改:`backend/src/routes/`、`backend/src/controllers/`
- 数据库 / 部署 / 认证优先改:`backend/prisma/`、`backend/src/utils/`、`docker-compose.yml`、`Dockerfile`、`deploy.sh`
- 主要使用 TypeScript,保持现有 2 空格缩进;先读上下文,尽量复用现有实现,不做无关改动。
- 默认至少执行:
- `cd frontend && npm run build`
- `cd backend && npm run build`
- 涉及接口改动时,额外检查相关页面流程与 `GET /api/health`
- 涉及 Prisma 或表结构改动时,额外执行:`cd backend && npm run prisma:generate`
- 不提交真实密码、数据库连接串或生产地址;相关配置优先走环境变量。

8
backend/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
uploads
coverage
*.log
.env
.env.*
.git

View File

@ -0,0 +1,47 @@
ALTER TABLE `person`
ADD COLUMN `main_department_id` INT NULL;
CREATE INDEX `person_main_department_id_idx`
ON `person`(`main_department_id`);
ALTER TABLE `person`
ADD CONSTRAINT `person_main_department_id_fkey`
FOREIGN KEY (`main_department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;
CREATE TABLE `person_department` (
`person_id` INT NOT NULL,
`department_id` INT NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`person_id`, `department_id`),
INDEX `person_department_department_id_idx`(`department_id`),
CONSTRAINT `person_department_person_id_fkey`
FOREIGN KEY (`person_id`) REFERENCES `person`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `person_department_department_id_fkey`
FOREIGN KEY (`department_id`) REFERENCES `department`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `person` AS `p`
LEFT JOIN `department` AS `d`
ON `d`.`source_group_id` = `p`.`source_main_department_id`
SET `p`.`main_department_id` = `d`.`id`
WHERE `p`.`source_system` = 'ding_hr'
AND `p`.`source_main_department_id` IS NOT NULL;
INSERT INTO `person_department` (`person_id`, `department_id`, `created_at`, `updated_at`)
SELECT DISTINCT
`id`,
`main_department_id`,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
FROM `person`
WHERE `main_department_id` IS NOT NULL
ON DUPLICATE KEY UPDATE
`updated_at` = VALUES(`updated_at`);

View File

@ -0,0 +1,77 @@
CREATE TABLE `sync_job` (
`id` INT NOT NULL AUTO_INCREMENT,
`sync_type` VARCHAR(30) NOT NULL,
`trigger_mode` VARCHAR(30) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'success',
`operator` VARCHAR(50) NULL,
`summary` TEXT NULL,
`result_payload` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `sync_job_sync_type_created_at_idx`(`sync_type`, `created_at`),
INDEX `sync_job_status_created_at_idx`(`status`, `created_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `sync_change` (
`id` INT NOT NULL AUTO_INCREMENT,
`sync_job_id` INT NOT NULL,
`entity_type` VARCHAR(20) NOT NULL,
`entity_key` VARCHAR(100) NULL,
`entity_name` VARCHAR(100) NOT NULL,
`change_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(150) NOT NULL,
`detail` TEXT NULL,
`before_snapshot` TEXT NULL,
`after_snapshot` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `sync_change_sync_job_id_idx`(`sync_job_id`),
INDEX `sync_change_entity_type_change_type_idx`(`entity_type`, `change_type`),
CONSTRAINT `sync_change_sync_job_id_fkey`
FOREIGN KEY (`sync_job_id`) REFERENCES `sync_job`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `notification` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`content` TEXT NULL,
`type` VARCHAR(30) NOT NULL DEFAULT 'info',
`level` VARCHAR(20) NOT NULL DEFAULT 'info',
`action_url` VARCHAR(255) NULL,
`related_sync_job_id` INT NULL,
`metadata` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `notification_related_sync_job_id_idx`(`related_sync_job_id`),
INDEX `notification_created_at_idx`(`created_at`),
CONSTRAINT `notification_related_sync_job_id_fkey`
FOREIGN KEY (`related_sync_job_id`) REFERENCES `sync_job`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `user_notification` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`notification_id` INT NOT NULL,
`is_read` BOOLEAN NOT NULL DEFAULT false,
`read_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `user_notification_user_id_notification_id_key`(`user_id`, `notification_id`),
INDEX `user_notification_user_id_is_read_created_at_idx`(`user_id`, `is_read`, `created_at`),
CONSTRAINT `user_notification_user_id_fkey`
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `user_notification_notification_id_fkey`
FOREIGN KEY (`notification_id`) REFERENCES `notification`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,17 @@
ALTER TABLE `sync_change`
ADD COLUMN `processing_status` VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER `after_snapshot`,
ADD COLUMN `processing_notes` TEXT NULL AFTER `processing_status`,
ADD COLUMN `processed_by` INT NULL AFTER `processing_notes`,
ADD COLUMN `processed_at` DATETIME(3) NULL AFTER `processed_by`;
CREATE INDEX `sync_change_processing_status_created_at_idx`
ON `sync_change`(`processing_status`, `created_at`);
CREATE INDEX `sync_change_processed_by_idx`
ON `sync_change`(`processed_by`);
ALTER TABLE `sync_change`
ADD CONSTRAINT `sync_change_processed_by_fkey`
FOREIGN KEY (`processed_by`) REFERENCES `user`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;

View File

@ -44,6 +44,8 @@ model Department {
children Department[] @relation("DepartmentHierarchy")
equipments Equipment[]
mainPersons Person[] @relation("PersonMainDepartment")
personDepartments PersonDepartment[]
@@index([status])
@@index([parent_id])
@ -156,6 +158,8 @@ model User {
disposal_approved DisposalRecord[] @relation("DisposalApprover")
// 维修记录关联
maintenance_records MaintenanceRecord[]
notifications UserNotification[]
processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy")
@@index([role])
@@index([status])
@ -183,6 +187,7 @@ model Person {
real_name String @db.VarChar(50)
id_number String? @unique @db.VarChar(50)
department String? @db.VarChar(100)
main_department_id Int?
position String? @db.VarChar(100)
phone String? @db.VarChar(20)
email String? @db.VarChar(100)
@ -198,16 +203,33 @@ model Person {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
borrow_records BorrowRecord[]
borrow_records BorrowRecord[]
mainDepartment Department? @relation("PersonMainDepartment", fields: [main_department_id], references: [id])
personDepartments PersonDepartment[]
@@index([nickname])
@@index([real_name])
@@index([status])
@@index([main_department_id])
@@index([source_system])
@@index([last_synced_at])
@@map("person")
}
model PersonDepartment {
person_id Int
department_id Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
person Person @relation(fields: [person_id], references: [id], onDelete: Cascade)
department Department @relation(fields: [department_id], references: [id], onDelete: Cascade)
@@id([person_id, department_id])
@@index([department_id])
@@map("person_department")
}
model StorageLocation {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
@ -242,6 +264,86 @@ model OperationLog {
@@map("operation_log")
}
model SyncJob {
id Int @id @default(autoincrement())
sync_type String @db.VarChar(30)
trigger_mode String @db.VarChar(30)
status String @default("success") @db.VarChar(20)
operator String? @db.VarChar(50)
summary String? @db.Text
result_payload String? @db.Text
created_at DateTime @default(now())
changes SyncChange[]
notifications Notification[]
@@index([sync_type, created_at])
@@index([status, created_at])
@@map("sync_job")
}
model SyncChange {
id Int @id @default(autoincrement())
sync_job_id Int
entity_type String @db.VarChar(20)
entity_key String? @db.VarChar(100)
entity_name String @db.VarChar(100)
change_type String @db.VarChar(50)
title String @db.VarChar(150)
detail String? @db.Text
before_snapshot String? @db.Text
after_snapshot String? @db.Text
processing_status String @default("pending") @db.VarChar(20)
processing_notes String? @db.Text
processed_by Int?
processed_at DateTime?
created_at DateTime @default(now())
syncJob SyncJob @relation(fields: [sync_job_id], references: [id], onDelete: Cascade)
processedBy User? @relation("SyncChangeProcessedBy", fields: [processed_by], references: [id], onDelete: SetNull)
@@index([sync_job_id])
@@index([entity_type, change_type])
@@index([processing_status, created_at])
@@index([processed_by])
@@map("sync_change")
}
model Notification {
id Int @id @default(autoincrement())
title String @db.VarChar(150)
content String? @db.Text
type String @default("info") @db.VarChar(30)
level String @default("info") @db.VarChar(20)
action_url String? @db.VarChar(255)
related_sync_job_id Int?
metadata String? @db.Text
created_at DateTime @default(now())
syncJob SyncJob? @relation(fields: [related_sync_job_id], references: [id], onDelete: SetNull)
recipients UserNotification[]
@@index([related_sync_job_id])
@@index([created_at])
@@map("notification")
}
model UserNotification {
id Int @id @default(autoincrement())
user_id Int
notification_id Int
is_read Boolean @default(false)
read_at DateTime?
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
notification Notification @relation(fields: [notification_id], references: [id], onDelete: Cascade)
@@unique([user_id, notification_id])
@@index([user_id, is_read, created_at])
@@map("user_notification")
}
model MountHistory {
id Int @id @default(autoincrement())

View File

@ -15,7 +15,18 @@ export const getEquipmentForBarcode = async (req: Request, res: Response) => {
equipment_name: true,
equipment_model: true,
equipment_type: true,
created_at: true
created_at: true,
purchase_date: true,
department: {
select: {
name: true
}
},
storageLocation: {
select: {
name: true
}
}
}
})
@ -28,7 +39,10 @@ export const getEquipmentForBarcode = async (req: Request, res: Response) => {
equipment_name: equipment.equipment_name,
equipment_model: equipment.equipment_model,
equipment_type: equipment.equipment_type,
created_at: equipment.created_at
created_at: equipment.created_at,
purchase_date: equipment.purchase_date,
department: equipment.department,
storageLocation: equipment.storageLocation
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取资产信息失败')
@ -54,7 +68,13 @@ export const getEquipmentBatchForBarcode = async (req: Request, res: Response) =
equipment_model: true,
equipment_type: true,
created_at: true,
purchase_date: true,
storage_location_id: true,
department: {
select: {
name: true
}
},
storageLocation: {
select: {
id: true,
@ -65,17 +85,6 @@ export const getEquipmentBatchForBarcode = async (req: Request, res: Response) =
}
})
// 调试:打印原始查询结果
console.log('条码打印 - 原始查询结果:', JSON.stringify(equipmentList, null, 2))
equipmentList.forEach((eq, index) => {
console.log(`原始资产 ${index + 1} (ID: ${eq.id}):`, {
name: eq.equipment_name,
storage_location_id: eq.storage_location_id,
storageLocation: eq.storageLocation,
allKeys: Object.keys(eq)
})
})
// 直接返回查询结果,不需要再次映射
const result = equipmentList.map(eq => ({
id: eq.id,
@ -84,20 +93,12 @@ export const getEquipmentBatchForBarcode = async (req: Request, res: Response) =
equipment_model: eq.equipment_model,
equipment_type: eq.equipment_type,
created_at: eq.created_at,
purchase_date: eq.purchase_date,
department: eq.department,
storage_location_id: eq.storage_location_id,
storageLocation: eq.storageLocation
}))
// 调试:打印返回的数据
console.log('条码打印 - 最终返回结果:', JSON.stringify(result, null, 2))
result.forEach((eq, index) => {
console.log(`资产 ${index + 1} (ID: ${eq.id}):`, {
name: eq.equipment_name,
storage_location_id: eq.storage_location_id,
storageLocation: eq.storageLocation
})
})
successResponse(res, result, '获取成功')
} catch (error: any) {
console.error('批量获取资产信息失败:', error)

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
const longTerm = Boolean(isLongTerm)
@ -55,7 +56,14 @@ const batchBorrowTransaction = async (
}
})
const nextStatus = equipment.remaining_quantity === 1 ? '已借出' : '可用'
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: nextStatus
})
const updated = await tx.equipment.updateMany({
where: {
id: equipment.id,
@ -63,9 +71,10 @@ const batchBorrowTransaction = async (
remaining_quantity: equipment.remaining_quantity
},
data: {
remaining_quantity: { decrement: 1 },
borrowed_value: { increment: Number(equipment.unit_price) },
available_value: { decrement: Number(equipment.unit_price) },
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
status: nextStatus
}
})
@ -73,6 +82,8 @@ const batchBorrowTransaction = async (
if (updated.count !== 1) {
throw new Error('资产库存已变化,请刷新后重试')
}
await syncMountedChildrenState(tx, equipment.id, '已借出')
})
}
@ -100,15 +111,26 @@ const batchReturnTransaction = async (
throw new Error('该借用记录已归还,请刷新后重试')
}
const nextRemainingQuantity = Math.min(
Number(equipment.total_quantity),
Number(equipment.remaining_quantity) + 1
)
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: '可用'
})
const updatedEquipment = await tx.equipment.updateMany({
where: {
id: equipment.id,
remaining_quantity: { lt: equipment.total_quantity }
},
data: {
remaining_quantity: { increment: 1 },
borrowed_value: { decrement: Number(equipment.unit_price) },
available_value: { increment: Number(equipment.unit_price) },
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
status: '可用'
}
})
@ -116,6 +138,8 @@ const batchReturnTransaction = async (
if (updatedEquipment.count !== 1) {
throw new Error('资产库存已达到上限,请检查归还记录')
}
await syncMountedChildrenState(tx, equipment.id, '可用')
})
}
@ -147,6 +171,17 @@ export const batchBorrow = async (req: Request, res: Response) => {
continue
}
if (equipment.parent_id) {
results.push({
equipment_id: equipmentId,
equipment_code: equipment.equipment_code,
status: 'failed',
message: '该子资产当前已挂载,请先卸载后再借用'
})
failedCount++
continue
}
if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) {
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'failed', message: '资产不可借用' })
failedCount++

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { generateEquipmentCode } from '../utils/equipmentCode'
import { calculateEquipmentValues } from '../utils/assetValue'
interface PersonImportData {
nickname: string
@ -26,6 +27,12 @@ interface EquipmentImportData {
description?: string
}
const normalizeOptionalText = (value: unknown) => {
if (value === undefined || value === null) return null
const text = String(value).trim()
return text || null
}
// 批量导入使用者
export const batchImportPersons = async (req: Request, res: Response) => {
try {
@ -35,6 +42,12 @@ export const batchImportPersons = async (req: Request, res: Response) => {
return errorResponse(res, '导入数据不能为空')
}
const activeDepartments = await prisma.department.findMany({
where: { status: 'active' },
select: { id: true, name: true }
})
const departmentMap = new Map(activeDepartments.map((department) => [department.name, department.id]))
const results = { success: 0, failed: 0, errors: [] as { row: number; message: string }[] }
for (let i = 0; i < persons.length; i++) {
@ -61,16 +74,33 @@ export const batchImportPersons = async (req: Request, res: Response) => {
}
}
await prisma.person.create({
data: {
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number || null,
department: person.department || null,
position: person.position || null,
phone: person.phone || null,
email: person.email || null,
status: 'active'
const normalizedDepartment = normalizeOptionalText(person.department)
const matchedDepartmentId = normalizedDepartment
? departmentMap.get(normalizedDepartment) || null
: null
await prisma.$transaction(async (tx) => {
const created = await tx.person.create({
data: {
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number || null,
department: normalizedDepartment,
main_department_id: matchedDepartmentId,
position: person.position || null,
phone: person.phone || null,
email: person.email || null,
status: 'active'
}
})
if (matchedDepartmentId) {
await tx.personDepartment.create({
data: {
person_id: created.id,
department_id: matchedDepartmentId
}
})
}
})
results.success++
@ -115,7 +145,7 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
prisma.storageLocation.findMany({ where: { status: 'active' } })
])
const assetTypeMap = new Map(assetTypes.map(t => [t.name, t.id]))
const assetTypeMap = new Map(assetTypes.map((type) => [type.name, type]))
const departmentMap = new Map(departments.map(d => [d.name, d.id]))
const locationMap = new Map(locations.map(l => [l.name, l.id]))
@ -134,13 +164,19 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
}
// 查找资产类型
const assetTypeId = assetTypeMap.get(eq.equipment_type)
if (!assetTypeId) {
const assetType = assetTypeMap.get(eq.equipment_type)
if (!assetType) {
results.errors.push({ row: rowNum, message: `资产类型 "${eq.equipment_type}" 不存在` })
results.failed++
continue
}
if (assetType.can_be_child && Number(eq.total_quantity) !== 1) {
results.errors.push({ row: rowNum, message: `子资产类型 "${eq.equipment_type}" 导入时数量必须为 1` })
results.failed++
continue
}
// 查找部门(可选)
let departmentId: number | null = null
if (eq.department) {
@ -169,7 +205,12 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
// 计算价值
const unitPrice = Number(eq.unit_price) || 0
const totalQuantity = Number(eq.total_quantity) || 1
const totalValue = unitPrice * totalQuantity
const values = calculateEquipmentValues({
unitPrice,
totalQuantity,
remainingQuantity: totalQuantity,
status: '可用'
})
await prisma.equipment.create({
data: {
@ -181,16 +222,16 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
unit_price: unitPrice,
total_quantity: totalQuantity,
remaining_quantity: totalQuantity,
total_value: totalValue,
borrowed_value: 0,
available_value: totalValue,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
supplier: eq.supplier || null,
purchase_date: eq.purchase_date ? new Date(eq.purchase_date) : null,
description: eq.description || null,
status: '可用',
storage_location_id: locationId,
department_id: departmentId,
asset_type_id: assetTypeId
asset_type_id: assetType.id
}
})
results.success++

View File

@ -1,6 +1,11 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import {
calculateEquipmentValues,
getDisplayEquipmentValue,
syncMountedChildrenState
} from '../utils/assetValue'
const parseNumber = (value: any): number | undefined => {
const num = Number(value)
@ -24,6 +29,15 @@ const equipmentInclude = {
}
}
const personInclude = {
mainDepartment: {
select: {
id: true,
name: true
}
}
}
const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => {
if (equipmentId) {
return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude })
@ -46,6 +60,9 @@ const ensurePersonAvailable = async (personId?: number) => {
}
const ensureEquipmentBorrowable = (equipment: any) => {
if (equipment.parent_id) {
throw new Error('该子资产当前已挂载,请先卸载后再借用')
}
// 检查报废状态
if (equipment.status === '待报废' || equipment.status === '已报废') {
throw new Error('资产已报废或待报废,不可借用')
@ -115,7 +132,14 @@ const borrowEquipmentInTransaction = async (
}
})
const nextStatus = equipment.remaining_quantity === 1 ? '已借出' : '可用'
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: nextStatus
})
const updateResult = await tx.equipment.updateMany({
where: {
id: equipment.id,
@ -123,9 +147,10 @@ const borrowEquipmentInTransaction = async (
remaining_quantity: equipment.remaining_quantity
},
data: {
remaining_quantity: { decrement: 1 },
borrowed_value: { increment: Number(equipment.unit_price) },
available_value: { decrement: Number(equipment.unit_price) },
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
status: nextStatus
}
})
@ -134,6 +159,8 @@ const borrowEquipmentInTransaction = async (
throw new Error('资产库存已变化,请刷新后重试')
}
await syncMountedChildrenState(tx, equipment.id, '已借出')
const updatedEquipment = await tx.equipment.findUnique({
where: { id: equipment.id },
include: equipmentInclude
@ -240,15 +267,26 @@ const returnEquipmentInTransaction = async (
throw new Error('该借用记录已归还,请刷新后重试')
}
const nextRemainingQuantity = Math.min(
Number(equipment.total_quantity),
Number(equipment.remaining_quantity) + 1
)
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: '可用'
})
const equipmentUpdate = await tx.equipment.updateMany({
where: {
id: equipment.id,
remaining_quantity: { lt: equipment.total_quantity }
},
data: {
remaining_quantity: { increment: 1 },
borrowed_value: { decrement: Number(equipment.unit_price) },
available_value: { increment: Number(equipment.unit_price) },
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
status: '可用'
}
})
@ -257,6 +295,8 @@ const returnEquipmentInTransaction = async (
throw new Error('资产库存已达到上限,请检查归还记录')
}
await syncMountedChildrenState(tx, equipment.id, '可用')
return {
id: borrowRecord.id,
person_id: borrowRecord.person_id,
@ -278,7 +318,8 @@ const mapBorrowRecord = (record: any) => ({
id: record.person.id,
nickname: record.person.nickname,
real_name: record.person.real_name,
department: record.person.department,
department: record.person.mainDepartment?.name || record.person.department,
main_department: record.person.mainDepartment || null,
phone: record.person.phone
}
: {
@ -772,7 +813,9 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => {
},
orderBy: { borrow_time: 'desc' },
include: {
person: true,
person: {
include: personInclude
},
equipment: {
select: {
equipment_name: true,
@ -800,7 +843,7 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => {
equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name,
unit_price: Number(equipment.unit_price),
equipment_value: Number(equipment.unit_price),
equipment_value: getDisplayEquipmentValue(equipment),
storage_location_id: equipment.storage_location_id,
storageLocation: equipment.storageLocation,
borrow_records: borrowRecords.map(mapBorrowRecord),
@ -817,7 +860,7 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => {
// 获取借用记录列表
export const getBorrowRecordList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, status, person_name, person_id } = req.query
const { page = 1, pageSize = 10, status, person_name, person_id, keyword } = req.query
const filters: any[] = []
@ -840,6 +883,20 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
]
})
}
if (keyword) {
const kw = keyword as string
filters.push({
OR: [
{ equipment_code: { contains: kw } },
{
equipment: {
equipment_name: { contains: kw }
}
}
]
})
}
const where = filters.length ? { AND: filters } : undefined
const skip = (Number(page) - 1) * Number(pageSize)
@ -849,9 +906,11 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
where,
skip,
take: Number(pageSize),
orderBy: { borrow_time: 'desc' },
include: {
person: true,
orderBy: { borrow_time: 'desc' },
include: {
person: {
include: personInclude
},
equipment: {
select: {
equipment_name: true,

View File

@ -15,6 +15,11 @@ const baseSelect = {
updated_at: true
}
const departmentCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base'
})
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>()
@ -43,11 +48,49 @@ const buildDepartmentTree = (
name: string
status: string
parent_id: number | null
source_system?: string | null
created_at: Date
updated_at: Date
_count: { equipments: number; children: number }
_count: { equipments: number; children: number; personDepartments: number }
}>
) => {
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 childrenMap = new Map<number | null, typeof departments>()
for (const department of departments) {
const siblings = childrenMap.get(department.parent_id) || []
@ -55,10 +98,15 @@ const buildDepartmentTree = (
childrenMap.set(department.parent_id, siblings)
}
childrenMap.forEach((siblings, key) => {
childrenMap.set(key, [...siblings].sort(compareDepartments))
})
const buildNode = (department: typeof departments[number]): any => ({
...department,
equipment_count: department._count.equipments,
children_count: department._count.children,
person_count: department._count.personDepartments,
children: (childrenMap.get(department.id) || []).map(buildNode)
})
@ -96,11 +144,11 @@ export const getDepartmentOptions = async (_req: Request, res: Response) => {
export const getDepartmentTree = async (_req: Request, res: Response) => {
try {
const departments = await prisma.department.findMany({
orderBy: { name: 'asc' },
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
_count: {
select: { equipments: true, children: true }
select: { equipments: true, children: true, personDepartments: true }
}
}
})
@ -139,11 +187,11 @@ export const getDepartmentList = async (req: Request, res: Response) => {
parent: {
select: { id: true, name: true }
},
_count: {
select: { equipments: true, children: true }
}
_count: {
select: { equipments: true, children: true, personDepartments: true }
}
}),
}
}),
prisma.department.count({ where })
])
@ -153,7 +201,8 @@ export const getDepartmentList = async (req: Request, res: Response) => {
parent_name: dept.parent?.name || null,
level: levelMap.get(dept.id) || 0,
equipment_count: dept._count.equipments,
children_count: dept._count.children
children_count: dept._count.children,
person_count: dept._count.personDepartments
}))
successResponse(
@ -317,7 +366,7 @@ export const deleteDepartment = async (req: Request, res: Response) => {
where: { id: Number(id) },
include: {
_count: {
select: { equipments: true, children: true }
select: { equipments: true, children: true, mainPersons: true, personDepartments: true }
}
}
})
@ -335,6 +384,10 @@ export const deleteDepartment = async (req: Request, res: Response) => {
return errorResponse(res, '该部门已被资产使用,无法删除')
}
if (existing._count.mainPersons > 0 || existing._count.personDepartments > 0) {
return errorResponse(res, '该部门已被人员主数据使用,无法删除')
}
await prisma.department.delete({
where: { id: Number(id) }
})

View File

@ -3,6 +3,7 @@ import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
// 创建报废申请
export const createDisposal = async (req: Request, res: Response) => {
@ -22,6 +23,10 @@ export const createDisposal = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404)
}
if (equipment.parent_id) {
return errorResponse(res, '该子资产当前已挂载,请先卸载后再申请报废', 422)
}
if (equipment.status === '待报废' || equipment.status === '已报废') {
return errorResponse(res, '资产已在报废流程中')
}
@ -33,6 +38,12 @@ export const createDisposal = async (req: Request, res: Response) => {
}
const disposal = await prisma.$transaction(async (tx) => {
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
status: '待报废'
})
const created = await tx.disposalRecord.create({
data: {
equipment_id: equipment.id,
@ -47,9 +58,16 @@ export const createDisposal = async (req: Request, res: Response) => {
await tx.equipment.update({
where: { id: equipment.id },
data: { status: '待报废' }
data: {
status: '待报废',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
await syncMountedChildrenState(tx, equipment.id, '待报废')
return created
})
@ -165,14 +183,28 @@ export const approveDisposal = async (req: Request, res: Response) => {
return
}
const values = calculateEquipmentValues({
unitPrice: Number(disposal.equipment?.unit_price || 0),
totalQuantity: Number(disposal.equipment?.total_quantity || 0),
remainingQuantity: Number(disposal.equipment?.remaining_quantity || 0),
status: '可用'
})
await tx.disposalRecord.update({
where: { id: Number(id) },
data: { status: 'rejected', approved_by: userId, approved_at: new Date(), notes: reject_reason }
})
await tx.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '可用' }
data: {
status: '可用',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
await syncMountedChildrenState(tx, disposal.equipment_id, '可用')
})
// 记录操作日志
@ -212,6 +244,17 @@ export const completeDisposal = async (req: Request, res: Response) => {
}
await prisma.$transaction(async (tx) => {
const equipment = await tx.equipment.findUnique({
where: { id: disposal.equipment_id }
})
const values = calculateEquipmentValues({
unitPrice: Number(equipment?.unit_price || 0),
totalQuantity: Number(equipment?.total_quantity || 0),
remainingQuantity: Number(equipment?.remaining_quantity || 0),
status: '已报废'
})
await tx.disposalRecord.update({
where: { id: Number(id) },
data: {
@ -224,8 +267,15 @@ export const completeDisposal = async (req: Request, res: Response) => {
await tx.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '已报废' }
data: {
status: '已报废',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
await syncMountedChildrenState(tx, disposal.equipment_id, '已报废')
})
// 记录操作日志

View File

@ -2,19 +2,37 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { generateEquipmentCode, validateEquipmentCode } from '../utils/equipmentCode'
import {
calculateEquipmentValues,
deriveParentBaseUnitPrice,
refreshParentTotalValueWithChildren,
sumBundleChildrenValue,
syncMountedChildrenState
} from '../utils/assetValue'
// 计算资产价值
const calculateEquipmentValues = (unitPrice: number, totalQuantity: number, remainingQuantity: number) => {
const totalValue = Number(unitPrice) * totalQuantity
const borrowedQuantity = totalQuantity - remainingQuantity
const borrowedValue = Number(unitPrice) * borrowedQuantity
const availableValue = Number(unitPrice) * remainingQuantity
return {
total_value: totalValue,
borrowed_value: borrowedValue,
available_value: availableValue
}
type EquipmentEntryMode = 'single' | 'parent' | 'child'
interface EquipmentCreateInput {
equipment_code?: string
equipment_name?: string
equipment_model?: string
equipment_type?: string
code_generation_mode?: 'auto' | 'manual'
unit_price?: number | string
total_quantity?: number | string
remaining_quantity?: number | string
purchase_date?: string | null
supplier?: string | null
description?: string | null
storage_location_id?: number | string | null
department_id?: number | string | null
asset_type_id?: number | string | null
status?: string
}
interface EquipmentBundleChildInput extends EquipmentCreateInput {
quantity?: number | string | null
notes?: string | null
}
const includeStorageLocation = {
@ -43,12 +61,48 @@ const includeStorageLocation = {
}
}
const ensureLocationExists = async (locationId?: number | null) => {
const includeEquipmentRelations = {
...includeStorageLocation,
parent: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
unit_price: true
}
},
children: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true,
status: true,
mount_status: true
}
}
}
const toOptionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return null
const numericValue = Number(value)
return Number.isNaN(numericValue) ? null : numericValue
}
const normalizeEntryMode = (value: unknown): EquipmentEntryMode => {
if (value === 'parent' || value === 'child') return value
return 'single'
}
const ensureLocationExists = async (client: any, locationId?: number | null) => {
if (!locationId) {
throw new Error('地址信息不能为空')
}
const location = await prisma.storageLocation.findUnique({
const location = await client.storageLocation.findUnique({
where: { id: locationId }
})
@ -59,12 +113,150 @@ const ensureLocationExists = async (locationId?: number | null) => {
return location
}
const ensureAssetTypeExists = async (client: any, equipmentType?: string, assetTypeId?: number | null) => {
const normalizedEquipmentType = equipmentType?.trim()
const assetType = assetTypeId
? await client.assetType.findUnique({
where: { id: assetTypeId }
})
: normalizedEquipmentType
? await client.assetType.findFirst({
where: { name: normalizedEquipmentType }
})
: null
if (!assetType || assetType.status !== 'active') {
throw new Error(`资产类型 "${normalizedEquipmentType || assetTypeId || ''}" 不存在或已停用`)
}
if (normalizedEquipmentType && assetType.name !== normalizedEquipmentType) {
throw new Error('资产类型信息不匹配,请重新选择后再保存')
}
return assetType
}
const resolveEquipmentCode = async (
client: any,
codeGenerationMode: 'auto' | 'manual',
equipmentCode: string | undefined,
equipmentType: string,
locationId?: number | null
) => {
if (codeGenerationMode === 'auto') {
let finalCode = await generateEquipmentCode(equipmentType, client, locationId)
const existingCheck = await client.equipment.findUnique({
where: { equipment_code: finalCode }
})
if (existingCheck) {
finalCode = await generateEquipmentCode(equipmentType, client, locationId)
}
return finalCode
}
if (!equipmentCode?.trim()) {
throw new Error('自定义录入模式下资产编码不能为空')
}
const normalizedCode = equipmentCode.trim().toUpperCase()
if (!validateEquipmentCode(normalizedCode)) {
throw new Error('资产编码格式不正确')
}
const existing = await client.equipment.findUnique({
where: { equipment_code: normalizedCode }
})
if (existing) {
throw new Error('资产编码已存在')
}
return normalizedCode
}
const createEquipmentRecord = async (
client: any,
input: EquipmentCreateInput,
options?: {
forceSingleQuantity?: boolean
parentId?: number | null
mountStatus?: 'none' | 'mounted' | 'unmounted'
}
) => {
const equipmentName = input.equipment_name?.trim()
if (!equipmentName) {
throw new Error('资产名称不能为空')
}
const locationId = toOptionalNumber(input.storage_location_id)
const departmentId = toOptionalNumber(input.department_id)
const assetTypeId = toOptionalNumber(input.asset_type_id)
await ensureLocationExists(client, locationId)
const assetType = await ensureAssetTypeExists(client, input.equipment_type, assetTypeId)
const codeGenerationMode = input.code_generation_mode === 'manual' ? 'manual' : 'auto'
const finalCode = await resolveEquipmentCode(
client,
codeGenerationMode,
input.equipment_code,
assetType.name,
locationId
)
const finalTotalQuantity = options?.forceSingleQuantity
? 1
: Math.max(1, Number(input.total_quantity) || 1)
const requestedRemainingQuantity = options?.forceSingleQuantity
? 1
: Number(input.remaining_quantity ?? finalTotalQuantity) || finalTotalQuantity
const remainingQuantity = Math.min(finalTotalQuantity, Math.max(0, requestedRemainingQuantity))
const unitPrice = Number(input.unit_price) || 0
const finalStatus = input.status || '可用'
const values = calculateEquipmentValues({
unitPrice,
totalQuantity: finalTotalQuantity,
remainingQuantity,
status: finalStatus
})
return client.equipment.create({
data: {
equipment_code: finalCode,
equipment_name: equipmentName,
equipment_model: input.equipment_model?.trim() || null,
equipment_type: assetType.name,
code_generation_mode: codeGenerationMode,
unit_price: unitPrice,
total_quantity: finalTotalQuantity,
remaining_quantity: remainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
purchase_date: input.purchase_date ? new Date(input.purchase_date) : null,
supplier: input.supplier?.trim() || null,
description: input.description?.trim() || null,
status: finalStatus,
storage_location_id: locationId || undefined,
department_id: departmentId || undefined,
asset_type_id: assetType.id,
parent_id: options?.parentId || null,
mount_status: options?.mountStatus || 'none'
}
})
}
// 获取设备列表
export const getEquipmentList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id } = req.query
const where: any = {}
const hasParentFilter = !Number.isNaN(Number(parent_id)) && Boolean(parent_id)
if (equipmentType) {
where.equipment_type = equipmentType
@ -75,11 +267,24 @@ export const getEquipmentList = async (req: Request, res: Response) => {
}
if (keyword) {
where.OR = [
const ownSearchConditions = [
{ equipment_name: { contains: keyword as string } },
{ equipment_code: { contains: keyword as string } },
{ equipment_model: { contains: keyword as string } }
]
where.OR = hasParentFilter
? ownSearchConditions
: [
...ownSearchConditions,
{
children: {
some: {
OR: ownSearchConditions
}
}
}
]
}
const locationId = Number(storage_location_id)
@ -99,18 +304,25 @@ export const getEquipmentList = async (req: Request, res: Response) => {
// 挂载状态筛选
if (mount_status && mount_status !== 'all') {
if (mount_status === 'mounted') {
where.mount_status = 'mounted'
if (hasParentFilter) {
if (mount_status === 'mounted') {
where.mount_status = 'mounted'
} else if (mount_status === 'unmounted') {
where.mount_status = { in: ['none', 'unmounted'] }
}
} else if (mount_status === 'mounted') {
where.children = { some: {} }
} else if (mount_status === 'unmounted') {
where.mount_status = { in: ['none', 'unmounted'] }
where.parent_id = null
where.children = { none: {} }
}
}
// 父资产筛选(显示某主机的所有配件)
const parentIdNum = Number(parent_id)
if (!Number.isNaN(parentIdNum) && parent_id) {
if (hasParentFilter) {
where.parent_id = parentIdNum
} else {
where.parent_id = null
}
const skip = (Number(page) - 1) * Number(pageSize)
@ -136,7 +348,10 @@ export const getEquipmentList = async (req: Request, res: Response) => {
equipment_code: true,
equipment_name: true,
equipment_model: true,
unit_price: true
equipment_type: true,
unit_price: true,
status: true,
mount_status: true
}
}
}
@ -166,30 +381,7 @@ export const getEquipmentDetail = async (req: Request, res: Response) => {
const equipment = await prisma.equipment.findUnique({
where: { id: Number(id) },
include: {
...includeStorageLocation,
parent: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
unit_price: true
}
},
children: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true,
status: true,
mount_status: true
}
}
}
include: includeEquipmentRelations
})
if (!equipment) {
@ -205,109 +397,163 @@ export const getEquipmentDetail = async (req: Request, res: Response) => {
// 新增资产
export const createEquipment = async (req: Request, res: Response) => {
try {
const {
equipment_code,
equipment_name,
equipment_model,
equipment_type,
code_generation_mode = 'auto',
unit_price,
total_quantity,
purchase_date,
supplier,
description,
storage_location_id,
department_id,
asset_type_id
} = req.body
// 生成或验证设备编码
let finalCode = equipment_code
const locationId = storage_location_id ? Number(storage_location_id) : null
const deptId = department_id ? Number(department_id) : null
const typeId = asset_type_id ? Number(asset_type_id) : null
if (code_generation_mode === 'auto') {
// 自动生成编码,确保唯一性(generateEquipmentCode内部已处理唯一性检查)
finalCode = await generateEquipmentCode(equipment_type, prisma, locationId)
// 再次检查编码唯一性(双重保险)
const existingCheck = await prisma.equipment.findUnique({
where: { equipment_code: finalCode }
})
if (existingCheck) {
// 如果编码已存在,重新生成(这种情况理论上不应该发生,但作为安全措施)
finalCode = await generateEquipmentCode(equipment_type, prisma, locationId)
}
} else {
if (!equipment_code) {
return errorResponse(res, '自定义录入模式下资产编码不能为空')
}
if (!validateEquipmentCode(equipment_code)) {
return errorResponse(res, '资产编码格式不正确')
}
// 检查编码是否已存在
const existing = await prisma.equipment.findUnique({
where: { equipment_code }
})
if (existing) {
return errorResponse(res, '资产编码已存在')
}
}
try {
await ensureLocationExists(locationId)
} catch (error: any) {
return errorResponse(res, error.message || '地址无效', 422)
}
const entryMode = normalizeEntryMode(req.body.entry_mode)
const userId = Number(res.locals.user?.id)
const locationId = toOptionalNumber(req.body.storage_location_id)
const departmentId = toOptionalNumber(req.body.department_id)
const assetTypeId = toOptionalNumber(req.body.asset_type_id)
const bundleChildren = Array.isArray(req.body.children) ? (req.body.children as EquipmentBundleChildInput[]) : []
// 计算价值
const remaining_quantity = total_quantity || 1
const values = calculateEquipmentValues(
Number(unit_price) || 0,
Number(total_quantity) || 1,
remaining_quantity
)
const created = await prisma.equipment.create({
data: {
equipment_code: finalCode,
equipment_name,
equipment_model,
equipment_type,
code_generation_mode,
unit_price: Number(unit_price) || 0,
total_quantity: Number(total_quantity) || 1,
remaining_quantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
purchase_date: purchase_date ? new Date(purchase_date) : null,
supplier,
description,
status: '可用',
storage_location_id: locationId || undefined,
department_id: deptId || undefined,
asset_type_id: typeId || undefined
const createdEquipment = await prisma.$transaction(async (tx) => {
if (entryMode === 'parent') {
if (!Number.isFinite(userId) || userId <= 0) {
throw new Error('用户未登录,无法创建父子资产')
}
const parentAssetType = await ensureAssetTypeExists(tx, req.body.equipment_type, assetTypeId)
if (!parentAssetType.can_be_parent) {
throw new Error('当前资产类型不能作为父资产')
}
const bundleChildrenTotalValue = sumBundleChildrenValue(bundleChildren)
const parentBaseUnitPrice = deriveParentBaseUnitPrice(req.body.unit_price, bundleChildrenTotalValue)
const parent = await createEquipmentRecord(
tx,
{
...req.body,
unit_price: parentBaseUnitPrice,
storage_location_id: locationId,
department_id: departmentId,
asset_type_id: parentAssetType.id
},
{
forceSingleQuantity: true,
mountStatus: 'none'
}
)
for (let index = 0; index < bundleChildren.length; index += 1) {
const childInput = bundleChildren[index]
const childName = childInput.equipment_name?.trim()
const childType = childInput.equipment_type?.trim()
const childQuantity = Math.max(1, Math.floor(Number(childInput.quantity) || 1))
const hasAnyValue = Boolean(
childName ||
childType ||
childInput.equipment_model?.trim() ||
childInput.description?.trim() ||
childInput.notes?.trim() ||
childQuantity > 1 ||
Number(childInput.unit_price || 0)
)
if (!hasAnyValue) {
continue
}
if (!childName || !childType) {
throw new Error(`第 ${index + 1} 个子资产还没填写完整`)
}
const childAssetType = await ensureAssetTypeExists(
tx,
childType,
toOptionalNumber(childInput.asset_type_id)
)
if (!childAssetType.can_be_child) {
throw new Error(`子资产类型 "${childAssetType.name}" 不能作为配件挂载`)
}
for (let quantityIndex = 0; quantityIndex < childQuantity; quantityIndex += 1) {
const child = await createEquipmentRecord(
tx,
{
...childInput,
purchase_date: childInput.purchase_date ?? req.body.purchase_date,
supplier: childInput.supplier ?? req.body.supplier,
storage_location_id: locationId,
department_id: departmentId,
asset_type_id: childAssetType.id
},
{
forceSingleQuantity: true,
parentId: parent.id,
mountStatus: 'mounted'
}
)
await tx.mountHistory.create({
data: {
child_id: child.id,
parent_id: parent.id,
operation_type: 'mount',
operated_by: userId,
notes: childInput.notes?.trim() || '创建父资产时自动挂载'
}
})
}
}
await refreshParentTotalValueWithChildren(tx, parent.id)
return parent
}
if (entryMode === 'child') {
const childAssetType = await ensureAssetTypeExists(tx, req.body.equipment_type, assetTypeId)
if (!childAssetType.can_be_child) {
throw new Error('当前资产类型不能作为子资产')
}
return createEquipmentRecord(
tx,
{
...req.body,
storage_location_id: locationId,
department_id: departmentId,
asset_type_id: childAssetType.id
},
{
forceSingleQuantity: true,
mountStatus: 'none'
}
)
}
return createEquipmentRecord(
tx,
{
...req.body,
storage_location_id: locationId,
department_id: departmentId,
asset_type_id: assetTypeId
},
{
forceSingleQuantity: false,
mountStatus: 'none'
}
)
})
const equipment = await prisma.equipment.findUnique({
where: { id: created.id },
include: includeStorageLocation
where: { id: createdEquipment.id },
include: includeEquipmentRelations
})
successResponse(res, equipment, '新增成功')
} catch (error: any) {
if (error.code === 'P2002') {
if (error.code === 'P2002' || error.message?.includes('资产编码已存在')) {
return errorResponse(res, '资产编码已存在')
}
errorResponse(res, error.message || '新增资产失败')
const statusCode =
error.message?.includes('不存在') ||
error.message?.includes('不能为空') ||
error.message?.includes('格式不正确') ||
error.message?.includes('不能作为') ||
error.message?.includes('填写完整')
? 422
: 500
errorResponse(res, error.message || '新增资产失败', statusCode)
}
}
@ -343,7 +589,7 @@ export const updateEquipment = async (req: Request, res: Response) => {
if (storage_location_id !== undefined) {
const normalizedLocationId = Number(storage_location_id)
try {
await ensureLocationExists(normalizedLocationId)
await ensureLocationExists(prisma, normalizedLocationId)
} catch (error: any) {
return errorResponse(res, error.message || '地址无效', 422)
}
@ -372,7 +618,18 @@ export const updateEquipment = async (req: Request, res: Response) => {
total_quantity !== undefined
? Math.max(finalTotalQuantity - borrowedQuantity, 0)
: currentEquipment.remaining_quantity
const values = calculateEquipmentValues(finalUnitPrice, finalTotalQuantity, newRemainingQuantity)
const resolvedStatus = status
?? (
['维护中', '待报废', '已报废', '已损坏'].includes(currentEquipment.status)
? currentEquipment.status
: (newRemainingQuantity > 0 ? '可用' : '已借出')
)
const values = calculateEquipmentValues({
unitPrice: finalUnitPrice,
totalQuantity: finalTotalQuantity,
remainingQuantity: newRemainingQuantity,
status: resolvedStatus
})
// 调试日志:观察请求与计算过程
console.log('[更新资产] 输入参数:', {
@ -419,7 +676,7 @@ export const updateEquipment = async (req: Request, res: Response) => {
borrowed_value: values.borrowed_value,
available_value: values.available_value,
// 若未显式传入状态,则根据剩余库存自动设定
status: status ?? (newRemainingQuantity > 0 ? '可用' : '已借出'),
status: resolvedStatus,
purchase_date: purchase_date ? new Date(purchase_date) : currentEquipment.purchase_date,
supplier,
description,
@ -430,6 +687,29 @@ export const updateEquipment = async (req: Request, res: Response) => {
}
})
const childCount = await prisma.equipment.count({
where: { parent_id: Number(id) }
})
if (
childCount > 0 &&
['可用', '已借出', '维护中', '待报废', '已报废'].includes(resolvedStatus)
) {
await syncMountedChildrenState(
prisma,
Number(id),
resolvedStatus as '可用' | '已借出' | '维护中' | '待报废' | '已报废'
)
}
if (childCount > 0) {
await refreshParentTotalValueWithChildren(prisma, Number(id))
}
if (currentEquipment.parent_id) {
await refreshParentTotalValueWithChildren(prisma, currentEquipment.parent_id)
}
console.log('[更新资产] 已写入:', {
id,
total_quantity: finalTotalQuantity,
@ -528,7 +808,7 @@ export const generateCode = async (req: Request, res: Response) => {
const code = await generateEquipmentCode(equipment_type, prisma, locationId)
successResponse(res, {
equipment_code: code,
code_format: '{类型简称}-{地址ID(4位)}-{序号(5位)}'
code_format: 'JS-{类型简称}-{序号(4位)}'
}, '生成成功')
} else {
errorResponse(res, '自定义录入模式不需要生成编码')

View File

@ -1,5 +1,12 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import {
getEquipmentDisplayAvailableValue,
getEquipmentDisplayBorrowedValue,
getEquipmentDisplayTotalValue,
getEquipmentMountedChildrenValue,
sumSnapshotChildrenValue
} from '../utils/assetValue'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ExcelJS = require('exceljs')
@ -218,7 +225,11 @@ export const exportEquipment = async (req: Request, res: Response) => {
{ header: '型号', key: 'equipment_model', width: 20 },
{ header: '使用部门', key: 'department', width: 15 },
{ header: '存放地址', key: 'storage_location', width: 20 },
{ header: '单价', key: 'unit_price', width: 12 },
{ header: '本体单价', key: 'unit_price', width: 12 },
{ header: '当前价值', key: 'display_total_value', width: 14 },
{ header: '挂载配件价值', key: 'mounted_children_value', width: 16 },
{ header: '借出价值', key: 'borrowed_value', width: 14 },
{ header: '可用价值', key: 'available_value', width: 14 },
{ header: '总数量', key: 'total_quantity', width: 10 },
{ header: '剩余数量', key: 'remaining_quantity', width: 10 },
{ header: '状态', key: 'status', width: 10 },
@ -239,6 +250,10 @@ export const exportEquipment = async (req: Request, res: Response) => {
department: eq.department?.name || '',
storage_location: eq.storageLocation?.name || '',
unit_price: Number(eq.unit_price),
display_total_value: getEquipmentDisplayTotalValue(eq),
mounted_children_value: getEquipmentMountedChildrenValue(eq),
borrowed_value: getEquipmentDisplayBorrowedValue(eq),
available_value: getEquipmentDisplayAvailableValue(eq),
total_quantity: eq.total_quantity,
remaining_quantity: eq.remaining_quantity,
status: eq.status,
@ -284,7 +299,16 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
where,
include: {
equipment: { select: { equipment_name: true, unit_price: true } },
person: { select: { nickname: true, real_name: true, department: true } }
person: {
select: {
nickname: true,
real_name: true,
department: true,
mainDepartment: {
select: { name: true }
}
}
}
},
orderBy: { borrow_time: 'desc' }
})
@ -301,6 +325,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
{ header: '借用时间', key: 'borrow_time', width: 20 },
{ header: '预计归还', key: 'expected_return_time', width: 20 },
{ header: '实际归还', key: 'return_time', width: 20 },
{ header: '借出价值', key: 'equipment_value', width: 14 },
{ header: '状态', key: 'status', width: 10 },
{ header: '归还状况', key: 'return_condition', width: 10 },
{ header: '备注', key: 'notes', width: 30 }
@ -315,10 +340,11 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
equipment_name: record.equipment?.equipment_name || '',
person_name: record.person_name,
person_nickname: record.person_nickname,
department: record.person?.department || '',
department: record.person?.mainDepartment?.name || record.person?.department || '',
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',
expected_return_time: record.is_long_term ? '长期' : (record.expected_return_time ? new Date(record.expected_return_time).toLocaleDateString('zh-CN') : ''),
return_time: record.return_time ? new Date(record.return_time).toLocaleString('zh-CN') : '',
equipment_value: Number(record.equipment?.unit_price || 0) + sumSnapshotChildrenValue(record.children_snapshot),
status: record.status,
return_condition: record.return_condition,
notes: record.notes || ''

View File

@ -3,6 +3,7 @@ import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
// 创建维修记录
export const createMaintenance = async (req: Request, res: Response) => {
@ -22,6 +23,10 @@ export const createMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404)
}
if (equipment.parent_id) {
return errorResponse(res, '该子资产当前已挂载,请先卸载后再送修', 422)
}
if (equipment.status === '维护中') {
return errorResponse(res, '资产已在维修中')
}
@ -36,6 +41,12 @@ export const createMaintenance = async (req: Request, res: Response) => {
}
const maintenance = await prisma.$transaction(async (tx) => {
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
status: '维护中'
})
const created = await tx.maintenanceRecord.create({
data: {
equipment_id: equipment.id,
@ -50,9 +61,16 @@ export const createMaintenance = async (req: Request, res: Response) => {
await tx.equipment.update({
where: { id: equipment.id },
data: { status: '维护中' }
data: {
status: '维护中',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
await syncMountedChildrenState(tx, equipment.id, '维护中')
return created
})
@ -185,7 +203,21 @@ export const completeMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '只能完成进行中的维修记录')
}
const equipment = await prisma.equipment.findUnique({
where: { id: maintenance.equipment_id }
})
if (!equipment) {
return errorResponse(res, '资产不存在', 404)
}
await prisma.$transaction(async (tx) => {
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
status: '可用'
})
await tx.maintenanceRecord.update({
where: { id: Number(id) },
data: {
@ -198,8 +230,15 @@ export const completeMaintenance = async (req: Request, res: Response) => {
await tx.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
data: {
status: '可用',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
await syncMountedChildrenState(tx, equipment.id, '可用')
})
// 记录操作日志
@ -239,7 +278,21 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '只能取消进行中的维修记录')
}
const equipment = await prisma.equipment.findUnique({
where: { id: maintenance.equipment_id }
})
if (!equipment) {
return errorResponse(res, '资产不存在', 404)
}
await prisma.$transaction(async (tx) => {
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
status: '可用'
})
await tx.maintenanceRecord.update({
where: { id: Number(id) },
data: { status: 'cancelled', repair_result: reason || '已取消' }
@ -247,8 +300,15 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
await tx.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
data: {
status: '可用',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
await syncMountedChildrenState(tx, equipment.id, '可用')
})
// 记录操作日志

View File

@ -1,36 +1,11 @@
import { Request, Response } from 'express'
import { Decimal } from '@prisma/client/runtime/library'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
async function calculateTotalValueWithChildren(parentId: number): Promise<Decimal> {
const parent = await prisma.equipment.findUnique({
where: { id: parentId },
include: {
children: {
select: { unit_price: true }
}
}
})
if (!parent) return new Decimal(0)
const childrenValue = parent.children.reduce(
(sum, child) => sum.add(child.unit_price),
new Decimal(0)
)
return parent.unit_price.add(childrenValue)
}
async function updateParentTotalValue(parentId: number): Promise<void> {
const totalValue = await calculateTotalValueWithChildren(parentId)
await prisma.equipment.update({
where: { id: parentId },
data: { total_value_with_children: totalValue }
})
}
import {
calculateChildrenTotalValueFromRecords,
refreshParentTotalValueWithChildren
} from '../utils/assetValue'
const ensureMountableEquipment = async (
equipment: { id: number; status: string; remaining_quantity: number },
@ -142,7 +117,7 @@ export const mountChild = async (req: Request, res: Response) => {
})
})
await updateParentTotalValue(numericParentId)
await refreshParentTotalValueWithChildren(prisma, numericParentId)
const updatedParent = await prisma.equipment.findUnique({
where: { id: numericParentId },
@ -221,7 +196,7 @@ export const unmountChild = async (req: Request, res: Response) => {
})
})
await updateParentTotalValue(numericParentId)
await refreshParentTotalValueWithChildren(prisma, numericParentId)
successResponse(res, null, '卸载成功')
} catch (error: any) {
@ -339,7 +314,7 @@ export const replaceChild = async (req: Request, res: Response) => {
})
})
await updateParentTotalValue(numericParentId)
await refreshParentTotalValueWithChildren(prisma, numericParentId)
successResponse(res, null, '更换成功')
} catch (error: any) {
@ -374,16 +349,13 @@ export const getChildren = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404)
}
const totalChildrenValue = parent.children.reduce(
(sum, child) => sum.add(child.unit_price),
new Decimal(0)
)
const totalChildrenValue = calculateChildrenTotalValueFromRecords(parent.children)
successResponse(
res,
{
children: parent.children,
total_children_value: Number(totalChildrenValue),
total_children_value: totalChildrenValue,
total_value_with_children: Number(parent.total_value_with_children)
},
'获取成功'

View File

@ -0,0 +1,167 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { errorResponse, successResponse } from '../utils/response'
const parseMetadata = (value: string | null) => {
if (!value) return null
try {
return JSON.parse(value)
} catch {
return null
}
}
const getCurrentUserId = (res: Response) => {
const userId = Number(res.locals.user?.id)
return Number.isNaN(userId) ? null : userId
}
const sanitizeNotification = (item: any) => ({
id: item.id,
is_read: item.is_read,
read_at: item.read_at,
created_at: item.created_at,
notification_id: item.notification_id,
title: item.notification.title,
content: item.notification.content,
type: item.notification.type,
level: item.notification.level,
action_url: item.notification.action_url,
related_sync_job_id: item.notification.related_sync_job_id,
metadata: parseMetadata(item.notification.metadata),
notification_created_at: item.notification.created_at
})
export const getNotificationList = async (req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const { page = 1, pageSize = 10, status = 'all' } = req.query
const where: any = { user_id: userId }
if (status === 'unread') {
where.is_read = false
}
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total, unreadCount] = await Promise.all([
prisma.userNotification.findMany({
where,
skip,
take: Number(pageSize),
orderBy: [{ is_read: 'asc' }, { created_at: 'desc' }],
include: {
notification: true
}
}),
prisma.userNotification.count({ where }),
prisma.userNotification.count({
where: {
user_id: userId,
is_read: false
}
})
])
successResponse(
res,
{
list: list.map(sanitizeNotification),
total,
unread_count: unreadCount,
page: Number(page),
pageSize: Number(pageSize)
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取站内消息失败')
}
}
export const getUnreadNotificationCount = async (_req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const count = await prisma.userNotification.count({
where: {
user_id: userId,
is_read: false
}
})
successResponse(res, { count }, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取未读消息数量失败')
}
}
export const markNotificationAsRead = async (req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const id = Number(req.params.id)
const existing = await prisma.userNotification.findFirst({
where: {
id,
user_id: userId
},
include: {
notification: true
}
})
if (!existing) {
return errorResponse(res, '消息不存在', 404)
}
const updated = await prisma.userNotification.update({
where: { id },
data: {
is_read: true,
read_at: existing.read_at || new Date()
},
include: {
notification: true
}
})
successResponse(res, sanitizeNotification(updated), '已标记为已读')
} catch (error: any) {
errorResponse(res, error.message || '标记已读失败')
}
}
export const markAllNotificationsAsRead = async (_req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const result = await prisma.userNotification.updateMany({
where: {
user_id: userId,
is_read: false
},
data: {
is_read: true,
read_at: new Date()
}
})
successResponse(res, { updated: result.count }, '全部标记为已读')
} catch (error: any) {
errorResponse(res, error.message || '全部标记已读失败')
}
}

View File

@ -2,40 +2,180 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
const sanitizePerson = (person: any) => ({
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number,
department: person.department,
position: person.position,
phone: person.phone,
email: person.email,
status: person.status,
remarks: person.remarks,
created_at: person.created_at,
updated_at: person.updated_at
})
const departmentSelect = {
id: true,
name: true,
status: true,
parent_id: true,
source_group_id: true,
source_system: true,
last_synced_at: true
}
const personInclude = {
mainDepartment: {
select: departmentSelect
},
personDepartments: {
select: {
department: {
select: departmentSelect
}
}
}
}
const normalizeOptionalText = (value: unknown) => {
if (value === undefined) return undefined
if (value === null) return null
const text = String(value).trim()
return text || null
}
const collectDepartments = (person: any) => {
const departments = [
...(person.mainDepartment ? [person.mainDepartment] : []),
...((person.personDepartments || []).map((item: any) => item.department).filter(Boolean) as any[])
]
const uniqueDepartments = new Map<number, any>()
for (const department of departments) {
if (!uniqueDepartments.has(department.id)) {
uniqueDepartments.set(department.id, department)
}
}
return Array.from(uniqueDepartments.values())
}
const sanitizePerson = (person: any) => {
const departments = collectDepartments(person)
const mainDepartment = person.mainDepartment || departments[0] || null
return {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number,
department: mainDepartment?.name || person.department || null,
main_department_id: person.main_department_id,
main_department: mainDepartment
? {
id: mainDepartment.id,
name: mainDepartment.name,
status: mainDepartment.status,
parent_id: mainDepartment.parent_id,
source_group_id: mainDepartment.source_group_id,
source_system: mainDepartment.source_system,
last_synced_at: mainDepartment.last_synced_at
}
: null,
departments: departments.map((department) => ({
id: department.id,
name: department.name,
status: department.status,
parent_id: department.parent_id,
source_group_id: department.source_group_id,
source_system: department.source_system,
last_synced_at: department.last_synced_at
})),
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,
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 buildPersonWhere = (keyword?: unknown, status?: unknown) => {
const where: any = {}
if (status) {
where.status = status
}
const kw = typeof keyword === 'string' ? keyword.trim() : ''
if (kw) {
where.OR = [
{ nickname: { contains: kw } },
{ real_name: { contains: kw } },
{ id_number: { contains: kw } },
{ department: { contains: kw } },
{
mainDepartment: {
is: {
name: { contains: kw }
}
}
},
{
personDepartments: {
some: {
department: {
name: { contains: kw }
}
}
}
}
]
}
return where
}
const resolveDepartmentByName = async (departmentName: string | null) => {
if (!departmentName) {
return null
}
return prisma.department.findFirst({
where: {
name: departmentName,
status: 'active'
},
select: {
id: true,
name: true
},
orderBy: { id: 'asc' }
})
}
const replacePersonDepartments = async (
tx: any,
personId: number,
departmentIds: number[]
) => {
await tx.personDepartment.deleteMany({
where: { person_id: personId }
})
if (departmentIds.length > 0) {
await tx.personDepartment.createMany({
data: departmentIds.map((departmentId) => ({
person_id: personId,
department_id: departmentId
})),
skipDuplicates: true
})
}
}
export const getPersonList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, keyword, status } = req.query
const where: any = {}
if (keyword) {
const kw = keyword as string
where.OR = [
{ nickname: { contains: kw } },
{ real_name: { contains: kw } },
{ id_number: { contains: kw } }
]
}
if (status) {
where.status = status
}
const where = buildPersonWhere(keyword, status)
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total] = await Promise.all([
@ -43,7 +183,8 @@ export const getPersonList = async (req: Request, res: Response) => {
where,
skip,
take: Number(pageSize),
orderBy: { created_at: 'desc' }
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
include: personInclude
}),
prisma.person.count({ where })
])
@ -66,10 +207,15 @@ export const getPersonList = async (req: Request, res: Response) => {
export const getPersonDetail = async (req: Request, res: Response) => {
try {
const { id } = req.params
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
const person = await prisma.person.findUnique({
where: { id: Number(id) },
include: personInclude
})
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
successResponse(res, sanitizePerson(person), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取使用者详情失败')
@ -94,25 +240,39 @@ export const createPerson = async (req: Request, res: Response) => {
return errorResponse(res, '花名与真实姓名为必填项', 422)
}
if (id_number) {
const exists = await prisma.person.findUnique({ where: { id_number } })
const normalizedIdNumber = normalizeOptionalText(id_number)
if (normalizedIdNumber) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '证件编号已存在', 409)
}
}
const created = await prisma.person.create({
data: {
nickname,
real_name,
id_number,
department,
position,
phone,
email,
status,
remarks
}
const normalizedDepartment = normalizeOptionalText(department) ?? null
const matchedDepartment = await resolveDepartmentByName(normalizedDepartment)
const created = await prisma.$transaction(async (tx) => {
const person = await tx.person.create({
data: {
nickname: String(nickname).trim(),
real_name: String(real_name).trim(),
id_number: normalizedIdNumber,
department: normalizedDepartment,
main_department_id: matchedDepartment?.id || null,
position: normalizeOptionalText(position),
phone: normalizeOptionalText(phone),
email: normalizeOptionalText(email),
status,
remarks: normalizeOptionalText(remarks)
}
})
await replacePersonDepartments(tx, person.id, matchedDepartment ? [matchedDepartment.id] : [])
return tx.person.findUnique({
where: { id: person.id },
include: personInclude
})
})
successResponse(res, sanitizePerson(created), '创建成功')
@ -124,6 +284,7 @@ export const createPerson = async (req: Request, res: Response) => {
export const updatePerson = async (req: Request, res: Response) => {
try {
const { id } = req.params
const personId = Number(id)
const {
nickname,
real_name,
@ -136,31 +297,57 @@ export const updatePerson = async (req: Request, res: Response) => {
remarks
} = req.body as Record<string, any>
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
const person = await prisma.person.findUnique({
where: { id: personId },
include: personInclude
})
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (id_number && id_number !== person.id_number) {
const exists = await prisma.person.findUnique({ where: { id_number } })
const normalizedIdNumber = normalizeOptionalText(id_number)
if (normalizedIdNumber && normalizedIdNumber !== person.id_number) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '证件编号已存在', 409)
}
}
const updated = await prisma.person.update({
where: { id: Number(id) },
data: {
nickname: nickname ?? person.nickname,
real_name: real_name ?? person.real_name,
id_number: id_number ?? person.id_number,
department,
position,
phone,
email,
status: status ?? person.status,
remarks
const hasDepartmentInput = Object.prototype.hasOwnProperty.call(req.body, 'department')
const normalizedDepartment = hasDepartmentInput
? normalizeOptionalText(department) ?? null
: person.department
const matchedDepartment = hasDepartmentInput
? await resolveDepartmentByName(normalizedDepartment)
: person.mainDepartment
const updated = await prisma.$transaction(async (tx) => {
await tx.person.update({
where: { id: personId },
data: {
nickname: nickname !== undefined ? String(nickname).trim() : person.nickname,
real_name: real_name !== undefined ? String(real_name).trim() : person.real_name,
id_number: id_number !== undefined ? normalizedIdNumber : person.id_number,
department: hasDepartmentInput ? normalizedDepartment : person.department,
main_department_id: hasDepartmentInput
? matchedDepartment?.id || null
: person.main_department_id,
position: position !== undefined ? normalizeOptionalText(position) : person.position,
phone: phone !== undefined ? normalizeOptionalText(phone) : person.phone,
email: email !== undefined ? normalizeOptionalText(email) : person.email,
status: status ?? person.status,
remarks: remarks !== undefined ? normalizeOptionalText(remarks) : person.remarks
}
})
if (hasDepartmentInput) {
await replacePersonDepartments(tx, personId, matchedDepartment ? [matchedDepartment.id] : [])
}
return tx.person.findUnique({
where: { id: personId },
include: personInclude
})
})
successResponse(res, sanitizePerson(updated), '更新成功')
@ -190,7 +377,8 @@ export const updatePersonStatus = async (req: Request, res: Response) => {
const updated = await prisma.person.update({
where: { id: Number(id) },
data: { status }
data: { status },
include: personInclude
})
successResponse(res, sanitizePerson(updated), '状态更新成功')
@ -199,7 +387,6 @@ export const updatePersonStatus = async (req: Request, res: Response) => {
}
}
export const deletePerson = async (req: Request, res: Response) => {
try {
const { id } = req.params
@ -210,7 +397,6 @@ export const deletePerson = async (req: Request, res: Response) => {
return errorResponse(res, '使用者不存在', 404)
}
// 检查是否有未归还的借用记录
const activeBorrowCount = await prisma.borrowRecord.count({
where: {
person_id: personId,
@ -223,6 +409,10 @@ export const deletePerson = async (req: Request, res: Response) => {
}
await prisma.$transaction(async (tx) => {
await tx.personDepartment.deleteMany({
where: { person_id: personId }
})
await tx.borrowRecord.deleteMany({
where: { person_id: personId }
})
@ -236,20 +426,15 @@ export const deletePerson = async (req: Request, res: Response) => {
}
}
// 获取使用者选项列表(用于下拉选择)
export const getPersonOptions = async (_req: Request, res: Response) => {
try {
const persons = await prisma.person.findMany({
where: { status: 'active' },
orderBy: { nickname: 'asc' },
select: {
id: true,
nickname: true,
real_name: true,
department: true
}
orderBy: [{ nickname: 'asc' }, { real_name: 'asc' }],
include: personInclude
})
successResponse(res, persons, '获取成功')
successResponse(res, persons.map(sanitizePerson), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取使用者选项失败')
}

View File

@ -1,17 +1,84 @@
import fs from 'fs'
import path from 'path'
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
const DEFAULT_SITE_NAME = '固定资产管理系统'
const DEFAULT_THEME_COLOR = '#409EFF'
const DEFAULT_LOGO_URL = ''
const DEFAULT_THEME_COLOR = '#10A37F'
const DEFAULT_LOGO_URL = '/branding/logo.svg'
const DEFAULT_FAVICON_URL = '/branding/favicon.svg'
const CUSTOM_LOGO_CANDIDATES = [
'custom-logo.svg',
'custom-logo.png',
'custom-logo.webp',
'custom-logo.jpg',
'custom-logo.jpeg'
]
const DEFAULT_LOGO_CANDIDATES = ['logo.svg', 'logo.png', 'logo.webp', 'logo.jpg', 'logo.jpeg']
const CUSTOM_FAVICON_CANDIDATES = [
'custom-favicon.ico',
'custom-favicon.svg',
'custom-favicon.png',
'custom-favicon.webp',
'custom-favicon.jpg',
'custom-favicon.jpeg'
]
const DEFAULT_FAVICON_CANDIDATES = [
'favicon.ico',
'favicon.svg',
'favicon.png',
'favicon.webp',
'favicon.jpg',
'favicon.jpeg'
]
const resolveBrandingDir = () => {
const candidates = [
process.env.BRANDING_DIR,
path.resolve(process.cwd(), 'branding'),
path.resolve(process.cwd(), '../frontend/public/branding'),
path.resolve(__dirname, '..', '..', 'branding'),
path.resolve(__dirname, '..', '..', '..', 'frontend', 'public', 'branding')
].filter(Boolean) as string[]
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]
}
const BRANDING_DIR = resolveBrandingDir()
const resolveBrandingAssetUrl = (candidates: string[], fallbackUrl: string) => {
for (const fileName of candidates) {
if (fs.existsSync(path.join(BRANDING_DIR, fileName))) {
return `/branding/${fileName}`
}
}
return fallbackUrl
}
const getResolvedLogoUrl = () =>
resolveBrandingAssetUrl(
[...CUSTOM_LOGO_CANDIDATES, ...DEFAULT_LOGO_CANDIDATES],
DEFAULT_LOGO_URL
)
const getResolvedFaviconUrl = () =>
resolveBrandingAssetUrl(
[
...CUSTOM_FAVICON_CANDIDATES,
...CUSTOM_LOGO_CANDIDATES,
...DEFAULT_FAVICON_CANDIDATES,
...DEFAULT_LOGO_CANDIDATES
],
getResolvedLogoUrl() || DEFAULT_FAVICON_URL
)
const mapConfigResponse = (config: any) => {
if (!config) {
return {
site_name: DEFAULT_SITE_NAME,
logo_url: DEFAULT_LOGO_URL,
favicon_url: '',
logo_url: getResolvedLogoUrl(),
favicon_url: getResolvedFaviconUrl(),
theme_color: DEFAULT_THEME_COLOR,
updated_at: null,
updated_by: null
@ -20,9 +87,9 @@ const mapConfigResponse = (config: any) => {
return {
site_name: config.site_name || DEFAULT_SITE_NAME,
logo_url: config.logo_url || DEFAULT_LOGO_URL,
favicon_url: config.favicon_url || '',
theme_color: config.theme_color || DEFAULT_THEME_COLOR,
logo_url: getResolvedLogoUrl(),
favicon_url: getResolvedFaviconUrl(),
theme_color: DEFAULT_THEME_COLOR,
updated_at: config.updated_at,
updated_by: config.updatedBy
? {
@ -57,7 +124,7 @@ export const getSiteConfig = async (req: Request, res: Response) => {
export const updateSiteConfig = async (req: Request, res: Response) => {
try {
const { site_name, theme_color } = req.body as { site_name?: string; theme_color?: string }
const { site_name } = req.body as { site_name?: string }
if (!site_name) {
return errorResponse(res, '网站名称不能为空', 422)
@ -69,11 +136,11 @@ export const updateSiteConfig = async (req: Request, res: Response) => {
const data: any = {
site_name: site_name.trim(),
theme_color: theme_color || DEFAULT_THEME_COLOR,
theme_color: DEFAULT_THEME_COLOR,
updated_at: new Date(),
updated_by: res.locals.user?.id ?? null,
logo_url: existing?.logo_url || DEFAULT_LOGO_URL,
favicon_url: existing?.favicon_url || null
favicon_url: existing?.favicon_url || DEFAULT_FAVICON_URL
}
const result = existing

View File

@ -2,6 +2,29 @@ import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import {
getEquipmentDisplayAvailableValue,
getEquipmentDisplayBorrowedValue,
getEquipmentDisplayItemValue,
getEquipmentDisplayTotalValue,
sumSnapshotChildrenValue
} from '../utils/assetValue'
type DisplayValueRow = {
equipment_type?: string | null
status?: string | null
total_value?: unknown
borrowed_value?: unknown
available_value?: unknown
total_value_with_children?: unknown
}
type DisplayValueSummary = {
total_value: number
borrowed_value: number
available_value: number
maintenance_value: number
}
const toNumber = (value: unknown) => {
if (value === null || value === undefined) return 0
@ -31,6 +54,28 @@ const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate
return where
}
const summarizeDisplayValues = (rows: DisplayValueRow[]): DisplayValueSummary =>
rows.reduce<DisplayValueSummary>(
(summary, row) => {
const totalValue = getEquipmentDisplayTotalValue(row)
summary.total_value += totalValue
summary.borrowed_value += getEquipmentDisplayBorrowedValue(row)
summary.available_value += getEquipmentDisplayAvailableValue(row)
if (row.status === '维护中') {
summary.maintenance_value += totalValue
}
return summary
},
{
total_value: 0,
borrowed_value: 0,
available_value: 0,
maintenance_value: 0
}
)
// 获取首页统计数据
export const getDashboardStats = async (req: Request, res: Response) => {
try {
@ -38,39 +83,67 @@ export const getDashboardStats = async (req: Request, res: Response) => {
const [
equipmentTotals,
availableInventoryTotals,
borrowableInventoryTotals,
maintenanceTotals,
inventoryByType,
displayValueRows,
todayBorrowCount,
todayReturnCount,
todayBorrowValueRows,
todayReturnValueRows
todayBorrowRecords,
todayReturnRecords
] = await Promise.all([
prisma.equipment.aggregate({
_sum: {
total_quantity: true,
remaining_quantity: true,
total_value: true,
borrowed_value: true,
available_value: true
}
}),
prisma.equipment.aggregate({
where: { status: '维护中' },
where: {
status: '可用'
},
_sum: {
total_quantity: true,
available_value: true
remaining_quantity: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
prisma.equipment.aggregate({
where: {
status: {
in: ['可用', '已借出']
}
},
_sum: {
total_quantity: true,
remaining_quantity: true
}
}),
prisma.equipment.aggregate({
where: { status: '维护中' },
_sum: {
total_quantity: true,
total_value: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type', 'status'],
_sum: {
total_quantity: true,
remaining_quantity: true
}
}),
prisma.equipment.findMany({
where: {
parent_id: null
},
orderBy: {
_sum: {
total_quantity: 'desc'
}
select: {
status: true,
total_value: true,
borrowed_value: true,
available_value: true,
total_value_with_children: true
}
}),
prisma.borrowRecord.count({
@ -87,33 +160,56 @@ export const getDashboardStats = async (req: Request, res: Response) => {
}
}
}),
prisma.$queryRaw<Array<{ total: unknown }>>(Prisma.sql`
SELECT COALESCE(SUM(e.unit_price), 0) AS total
FROM borrow_record br
INNER JOIN equipment e ON e.id = br.equipment_id
WHERE br.borrow_time >= ${startOfToday}
`),
prisma.$queryRaw<Array<{ total: unknown }>>(Prisma.sql`
SELECT COALESCE(SUM(e.unit_price), 0) AS total
FROM borrow_record br
INNER JOIN equipment e ON e.id = br.equipment_id
WHERE br.return_time >= ${startOfToday}
`)
prisma.borrowRecord.findMany({
where: {
borrow_time: {
gte: startOfToday
}
},
select: {
children_snapshot: true,
equipment: {
select: {
unit_price: true
}
}
}
}),
prisma.borrowRecord.findMany({
where: {
return_time: {
gte: startOfToday
}
},
select: {
children_snapshot: true,
equipment: {
select: {
unit_price: true
}
}
}
})
])
const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity)
const availableEquipmentQuantity = toNumber(equipmentTotals._sum.remaining_quantity)
const availableEquipmentQuantity = toNumber(availableInventoryTotals._sum.remaining_quantity)
const borrowedEquipmentQuantity = Math.max(
totalEquipmentQuantity - availableEquipmentQuantity,
toNumber(borrowableInventoryTotals._sum.total_quantity) - toNumber(borrowableInventoryTotals._sum.remaining_quantity),
0
)
const maintenanceEquipmentQuantity = toNumber(maintenanceTotals._sum.total_quantity)
const totalValue = toNumber(equipmentTotals._sum.total_value)
const borrowedValue = toNumber(equipmentTotals._sum.borrowed_value)
const availableValue = toNumber(equipmentTotals._sum.available_value)
const maintenanceValue = toNumber(maintenanceTotals._sum.available_value)
const todayBorrowValue = toNumber(todayBorrowValueRows[0]?.total)
const todayReturnValue = toNumber(todayReturnValueRows[0]?.total)
const displayValueSummary = summarizeDisplayValues(displayValueRows)
const totalValue = displayValueSummary.total_value
const borrowedValue = displayValueSummary.borrowed_value
const availableValue = displayValueSummary.available_value
const maintenanceValue = displayValueSummary.maintenance_value
const todayBorrowValue = todayBorrowRecords.reduce((sum, record) => {
return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot)
}, 0)
const todayReturnValue = todayReturnRecords.reduce((sum, record) => {
return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot)
}, 0)
const usageRate =
totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0
const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0
@ -133,17 +229,37 @@ export const getDashboardStats = async (req: Request, res: Response) => {
value_usage_rate: valueUsageRate,
today_borrow_value: todayBorrowValue,
today_return_value: todayReturnValue,
inventory_by_type: inventoryByType
.map((item) => {
const totalQuantity = toNumber(item._sum.total_quantity)
const availableQuantity = toNumber(item._sum.remaining_quantity)
return {
equipment_type: item.equipment_type || '其他',
total_quantity: totalQuantity,
available_quantity: availableQuantity,
borrowed_quantity: Math.max(totalQuantity - availableQuantity, 0)
inventory_by_type: Array.from(
inventoryByType.reduce((map, item) => {
const equipmentType = item.equipment_type || '其他'
const current = map.get(equipmentType) || {
equipment_type: equipmentType,
total_quantity: 0,
available_quantity: 0,
borrowed_quantity: 0
}
})
const totalQuantity = toNumber(item._sum.total_quantity)
const remainingQuantity = toNumber(item._sum.remaining_quantity)
current.total_quantity += totalQuantity
if (item.status === '可用') {
current.available_quantity += remainingQuantity
current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0)
} else if (item.status === '已借出') {
current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0)
}
map.set(equipmentType, current)
return map
}, new Map<string, {
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
}>()).values()
)
.sort((a, b) => b.total_quantity - a.total_quantity)
.filter((item) => item.total_quantity > 0)
}, '获取成功')
} catch (error: any) {
@ -162,36 +278,21 @@ export const getValueStatistics = async (req: Request, res: Response) => {
typeof end_date === 'string' ? end_date : undefined
)
const [summary, maintenanceSummary, valueByTypeGroups, topEquipmentRows] = await Promise.all([
prisma.equipment.aggregate({
where,
_sum: {
const displayValueWhere: Prisma.EquipmentWhereInput = {
...where,
parent_id: null
}
const [displayValueRows, topEquipmentRows] = await Promise.all([
prisma.equipment.findMany({
where: displayValueWhere,
select: {
equipment_type: true,
status: true,
total_value: true,
borrowed_value: true,
available_value: true
}
}),
prisma.equipment.aggregate({
where: {
...where,
status: '维护中'
},
_sum: {
available_value: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
where,
_sum: {
total_value: true,
borrowed_value: true,
available_value: true
},
orderBy: {
_sum: {
total_value: 'desc'
}
available_value: true,
total_value_with_children: true
}
}),
prisma.equipment.findMany({
@ -199,42 +300,61 @@ export const getValueStatistics = async (req: Request, res: Response) => {
select: {
equipment_name: true,
unit_price: true,
total_quantity: true,
total_value: true,
total_value_with_children: true,
borrowed_value: true,
available_value: true
available_value: true,
status: true
},
orderBy: {
total_value: 'desc'
},
take: 10
})
])
const totalValue = toNumber(summary._sum.total_value)
const borrowedValue = toNumber(summary._sum.borrowed_value)
const availableValue = toNumber(summary._sum.available_value)
const maintenanceValue = toNumber(maintenanceSummary._sum.available_value)
const valueByType = valueByTypeGroups
.map((item) => ({
equipment_type: item.equipment_type || '未分类',
total_value: toNumber(item._sum.total_value),
borrowed_value: toNumber(item._sum.borrowed_value),
available_value: toNumber(item._sum.available_value)
}))
const summary = summarizeDisplayValues(displayValueRows)
const valueByType = Array.from(
displayValueRows.reduce((map, item) => {
const equipmentType = item.equipment_type || '未分类'
const current = map.get(equipmentType) || {
equipment_type: equipmentType,
total_value: 0,
borrowed_value: 0,
available_value: 0
}
current.total_value += getEquipmentDisplayTotalValue(item)
current.borrowed_value += getEquipmentDisplayBorrowedValue(item)
current.available_value += getEquipmentDisplayAvailableValue(item)
map.set(equipmentType, current)
return map
}, new Map<string, {
equipment_type: string
total_value: number
borrowed_value: number
available_value: number
}>())
.values()
)
.sort((left, right) => right.total_value - left.total_value)
.filter((item) => item.total_value > 0)
const topEquipment = topEquipmentRows.map((eq) => ({
equipment_name: eq.equipment_name,
unit_price: toNumber(eq.unit_price),
total_value: toNumber(eq.total_value),
borrowed_value: toNumber(eq.borrowed_value),
available_value: toNumber(eq.available_value)
}))
const topEquipment = topEquipmentRows
.map((eq) => ({
equipment_name: eq.equipment_name,
quantity: toNumber(eq.total_quantity) || 1,
unit_price: toNumber(eq.unit_price),
item_value: getEquipmentDisplayItemValue(eq),
stock_total_value: getEquipmentDisplayTotalValue(eq),
stock_borrowed_value: getEquipmentDisplayBorrowedValue(eq),
stock_available_value: getEquipmentDisplayAvailableValue(eq)
}))
.sort((a, b) => b.item_value - a.item_value || b.stock_total_value - a.stock_total_value)
.slice(0, 10)
successResponse(res, {
total_value: totalValue,
borrowed_value: borrowedValue,
available_value: availableValue,
maintenance_value: maintenanceValue,
total_value: summary.total_value,
borrowed_value: summary.borrowed_value,
available_value: summary.available_value,
maintenance_value: summary.maintenance_value,
value_by_type: valueByType,
top_equipment: topEquipment
}, '获取成功')

View File

@ -1,13 +1,101 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { errorResponse, successResponse } from '../utils/response'
import { previewHrSync, runHrSync } from '../services/hrSyncService'
import { getHrSyncConfigStatus, previewHrSync, runHrSync } from '../services/hrSyncService'
const SYNC_CHANGE_PROCESSING_STATUSES = ['pending', 'processed', 'ignored'] as const
type SyncChangeProcessingStatus = (typeof SYNC_CHANGE_PROCESSING_STATUSES)[number]
const resolveSyncErrorCode = (message: string) => {
if (message.includes('SQL Server 同步配置不完整')) {
return 400
}
return 500
}
const parseJsonText = (value: string | null) => {
if (!value) return null
try {
return JSON.parse(value)
} catch {
return null
}
}
const toRecord = (value: unknown) =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, number>) : {}
const normalizeProcessingStatus = (value: unknown): SyncChangeProcessingStatus | null => {
if (typeof value !== 'string') return null
const normalized = value.trim().toLowerCase()
return SYNC_CHANGE_PROCESSING_STATUSES.includes(normalized as SyncChangeProcessingStatus)
? (normalized as SyncChangeProcessingStatus)
: null
}
const normalizeProcessingNotes = (value: unknown) => {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized ? normalized : null
}
const serializeSyncJob = (job: any) => {
const payload = parseJsonText(job.result_payload)
return {
id: job.id,
sync_type: job.sync_type,
trigger_mode: job.trigger_mode,
status: job.status,
operator: job.operator,
summary: job.summary,
created_at: job.created_at,
changes_count:
typeof payload?.changes_count === 'number' ? payload.changes_count : job._count?.changes || 0,
counts: toRecord(payload?.counts),
error_message: typeof payload?.error_message === 'string' ? payload.error_message : null
}
}
const resolveProcessedByName = (change: any) => {
if (!change.processedBy) return null
return change.processedBy.name || change.processedBy.username || null
}
const serializeSyncChange = (change: any) => ({
id: change.id,
entity_type: change.entity_type,
entity_key: change.entity_key,
entity_name: change.entity_name,
change_type: change.change_type,
title: change.title,
detail: change.detail,
before_snapshot: parseJsonText(change.before_snapshot),
after_snapshot: parseJsonText(change.after_snapshot),
processing_status: change.processing_status,
processing_notes: change.processing_notes,
processed_by: change.processed_by,
processed_by_name: resolveProcessedByName(change),
processed_at: change.processed_at,
created_at: change.created_at
})
export const getHrSyncStatusController = async (_req: Request, res: Response) => {
try {
const result = getHrSyncConfigStatus()
successResponse(res, result, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取同步状态失败', 500)
}
}
export const previewHrSyncController = async (_req: Request, res: Response) => {
try {
const result = await previewHrSync()
successResponse(res, result, '预览成功')
} catch (error: any) {
errorResponse(res, error.message || '预览同步数据失败', 500)
const message = error.message || '预览同步数据失败'
errorResponse(res, message, resolveSyncErrorCode(message))
}
}
@ -17,6 +105,170 @@ export const runHrSyncController = async (_req: Request, res: Response) => {
const result = await runHrSync(operator)
successResponse(res, result, '同步成功')
} catch (error: any) {
errorResponse(res, error.message || '执行同步失败', 500)
const message = error.message || '执行同步失败'
errorResponse(res, message, resolveSyncErrorCode(message))
}
}
export const getHrSyncJobListController = 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 skip = (page - 1) * pageSize
const where = {
sync_type: 'hr'
}
const [list, total] = await Promise.all([
prisma.syncJob.findMany({
where,
skip,
take: pageSize,
orderBy: { created_at: 'desc' },
include: {
_count: {
select: {
changes: true
}
}
}
}),
prisma.syncJob.count({ where })
])
successResponse(
res,
{
list: list.map(serializeSyncJob),
total,
page,
pageSize
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取同步历史失败', 500)
}
}
export const getHrSyncJobDetailController = async (req: Request, res: Response) => {
try {
const id = Number(req.params.id)
if (!Number.isInteger(id) || id <= 0) {
return errorResponse(res, '同步任务不存在', 404)
}
const job = await prisma.syncJob.findFirst({
where: {
id,
sync_type: 'hr'
},
include: {
_count: {
select: {
changes: true
}
},
changes: {
include: {
processedBy: {
select: {
id: true,
username: true,
name: true
}
}
},
orderBy: { created_at: 'desc' }
}
}
})
if (!job) {
return errorResponse(res, '同步任务不存在', 404)
}
successResponse(
res,
{
...serializeSyncJob(job),
changes: job.changes.map(serializeSyncChange)
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取同步任务详情失败', 500)
}
}
export const updateHrSyncChangeProcessingController = async (req: Request, res: Response) => {
try {
const id = Number(req.params.id)
if (!Number.isInteger(id) || id <= 0) {
return errorResponse(res, '同步变更不存在', 404)
}
const processingStatus = normalizeProcessingStatus(req.body?.processing_status)
if (!processingStatus) {
return errorResponse(res, '处理状态无效', 400)
}
const currentUserId = Number(res.locals.user?.id)
if (processingStatus !== 'pending' && (!Number.isInteger(currentUserId) || currentUserId <= 0)) {
return errorResponse(res, '未获取到当前处理人信息', 401)
}
const existingChange = await prisma.syncChange.findFirst({
where: {
id,
syncJob: {
sync_type: 'hr'
}
},
select: {
id: true
}
})
if (!existingChange) {
return errorResponse(res, '同步变更不存在', 404)
}
const now = new Date()
const updateData =
processingStatus === 'pending'
? {
processing_status: processingStatus,
processing_notes: normalizeProcessingNotes(req.body?.processing_notes),
processed_by: null,
processed_at: null
}
: {
processing_status: processingStatus,
processing_notes: normalizeProcessingNotes(req.body?.processing_notes),
processed_by: currentUserId,
processed_at: now
}
const updatedChange = await prisma.syncChange.update({
where: {
id
},
data: updateData,
include: {
processedBy: {
select: {
id: true,
username: true,
name: true
}
}
}
})
successResponse(res, serializeSyncChange(updatedChange), '更新成功')
} catch (error: any) {
errorResponse(res, error.message || '更新同步变更处理状态失败', 500)
}
}

View File

@ -24,6 +24,7 @@ import batchRoutes from './routes/batch'
import exportRoutes from './routes/export'
import searchRoutes from './routes/search'
import syncRoutes from './routes/sync'
import notificationRoutes from './routes/notifications'
import { keycloak, memoryStore } from './utils/keycloak'
import { protect, requireAdmin } from './middleware/auth'
import { startHrSyncScheduler } from './services/hrSyncService'
@ -79,6 +80,7 @@ app.use('/api/batch', protect(), requireAdmin(), batchRoutes)
app.use('/api/export', protect(), exportRoutes)
app.use('/api/search', protect(), searchRoutes)
app.use('/api/sync', protect(), requireAdmin(), syncRoutes)
app.use('/api/notifications', notificationRoutes)
// Health check
app.get('/api/health', (req, res) => {

View File

@ -0,0 +1,19 @@
import { Router } from 'express'
import {
getNotificationList,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead
} from '../controllers/notificationController'
import { protect } from '../middleware/auth'
const router = Router()
router.use(protect())
router.get('/', getNotificationList)
router.get('/unread-count', getUnreadNotificationCount)
router.patch('/read-all', markAllNotificationsAsRead)
router.patch('/:id/read', markNotificationAsRead)
export default router

View File

@ -1,8 +1,22 @@
import { Router } from 'express'
import { previewHrSyncController, runHrSyncController } from '../controllers/syncController'
import {
getHrSyncJobDetailController,
getHrSyncJobListController,
getHrSyncStatusController,
previewHrSyncController,
runHrSyncController,
updateHrSyncChangeProcessingController
} from '../controllers/syncController'
import { protect, requireAdmin } from '../middleware/auth'
const router = Router()
router.use(protect(), requireAdmin())
router.get('/hr/jobs', getHrSyncJobListController)
router.get('/hr/jobs/:id', getHrSyncJobDetailController)
router.patch('/hr/changes/:id/processing', updateHrSyncChangeProcessingController)
router.get('/hr/status', getHrSyncStatusController)
router.get('/hr/preview', previewHrSyncController)
router.post('/hr/run', runHrSyncController)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
import type { Prisma, PrismaClient } from '@prisma/client'
type EquipmentClient = PrismaClient | Prisma.TransactionClient
const NON_AVAILABLE_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏'])
const toSafeNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return 0
}
const numericValue = Number(value)
return Number.isNaN(numericValue) ? 0 : numericValue
}
const clampRemainingQuantity = (totalQuantity: number, remainingQuantity: number) => {
if (remainingQuantity < 0) return 0
if (remainingQuantity > totalQuantity) return totalQuantity
return remainingQuantity
}
export const calculateEquipmentValues = ({
unitPrice,
totalQuantity,
remainingQuantity,
status
}: {
unitPrice: number
totalQuantity: number
remainingQuantity: number
status?: string | null
}) => {
const normalizedUnitPrice = toSafeNumber(unitPrice)
const normalizedTotalQuantity = Math.max(0, Math.floor(toSafeNumber(totalQuantity)))
const normalizedRemainingQuantity = clampRemainingQuantity(
normalizedTotalQuantity,
Math.floor(toSafeNumber(remainingQuantity))
)
const totalValue = normalizedUnitPrice * normalizedTotalQuantity
if (status && NON_AVAILABLE_STATUSES.has(status)) {
return {
total_value: totalValue,
borrowed_value: 0,
available_value: 0
}
}
const borrowedQuantity = Math.max(normalizedTotalQuantity - normalizedRemainingQuantity, 0)
return {
total_value: totalValue,
borrowed_value: normalizedUnitPrice * borrowedQuantity,
available_value: normalizedUnitPrice * normalizedRemainingQuantity
}
}
export const sumBundleChildrenValue = (
children: Array<{
unit_price?: number | string | null
quantity?: number | string | null
}>
) => {
return children.reduce((sum, child) => {
const quantity = Math.max(1, Math.floor(toSafeNumber(child.quantity) || 1))
return sum + toSafeNumber(child.unit_price) * quantity
}, 0)
}
export const deriveParentBaseUnitPrice = (bundleTotalPrice: unknown, childrenTotalValue: number) => {
const normalizedBundleTotalPrice = toSafeNumber(bundleTotalPrice)
const normalizedChildrenTotalValue = toSafeNumber(childrenTotalValue)
if (normalizedChildrenTotalValue > normalizedBundleTotalPrice) {
throw new Error('整机总价不能小于子资产总价,请先调整价格后再保存')
}
return normalizedBundleTotalPrice - normalizedChildrenTotalValue
}
export const calculateChildrenTotalValueFromRecords = (
children: Array<{ unit_price?: unknown }>
) => {
return children.reduce((sum, child) => sum + toSafeNumber(child.unit_price), 0)
}
export const refreshParentTotalValueWithChildren = async (client: EquipmentClient, parentId: number) => {
const parent = await client.equipment.findUnique({
where: { id: parentId },
include: {
children: {
select: {
unit_price: true
}
}
}
})
if (!parent) {
return
}
const totalValueWithChildren =
toSafeNumber(parent.unit_price) + calculateChildrenTotalValueFromRecords(parent.children)
await client.equipment.update({
where: { id: parentId },
data: {
total_value_with_children: totalValueWithChildren
}
})
}
export const syncMountedChildrenState = async (
client: EquipmentClient,
parentId: number,
targetStatus: '可用' | '已借出' | '维护中' | '待报废' | '已报废'
) => {
const children = await client.equipment.findMany({
where: { parent_id: parentId },
select: {
id: true,
unit_price: true,
total_quantity: true
}
})
for (const child of children) {
const remainingQuantity = targetStatus === '已借出' ? 0 : Number(child.total_quantity)
const values = calculateEquipmentValues({
unitPrice: Number(child.unit_price),
totalQuantity: Number(child.total_quantity),
remainingQuantity,
status: targetStatus
})
await client.equipment.update({
where: { id: child.id },
data: {
status: targetStatus,
remaining_quantity: remainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
}
}
export const getDisplayEquipmentValue = (equipment: {
unit_price?: unknown
total_value_with_children?: unknown
}) => {
const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children)
if (totalValueWithChildren > 0) {
return totalValueWithChildren
}
return toSafeNumber(equipment.unit_price)
}
export const getEquipmentDisplayTotalValue = (equipment: {
total_value?: unknown
total_value_with_children?: unknown
}) => {
const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children)
if (totalValueWithChildren > 0) {
return totalValueWithChildren
}
return toSafeNumber(equipment.total_value)
}
export const getEquipmentDisplayItemValue = (equipment: {
unit_price?: unknown
total_quantity?: unknown
total_value?: unknown
total_value_with_children?: unknown
}) => {
const displayTotalValue = getEquipmentDisplayTotalValue(equipment)
const totalQuantity = Math.max(1, Math.floor(toSafeNumber(equipment.total_quantity) || 1))
if (displayTotalValue <= 0) {
return toSafeNumber(equipment.unit_price)
}
return displayTotalValue / totalQuantity
}
export const getEquipmentDisplayBorrowedValue = (equipment: {
borrowed_value?: unknown
total_value_with_children?: unknown
status?: unknown
}) => {
const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children)
if (totalValueWithChildren > 0) {
return equipment.status === '已借出' ? totalValueWithChildren : 0
}
return toSafeNumber(equipment.borrowed_value)
}
export const getEquipmentDisplayAvailableValue = (equipment: {
available_value?: unknown
total_value_with_children?: unknown
status?: unknown
}) => {
const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children)
if (totalValueWithChildren > 0) {
return equipment.status === '可用' ? totalValueWithChildren : 0
}
return toSafeNumber(equipment.available_value)
}
export const getEquipmentMountedChildrenValue = (equipment: {
total_value?: unknown
total_value_with_children?: unknown
}) => {
const displayTotalValue = getEquipmentDisplayTotalValue(equipment)
const ownTotalValue = toSafeNumber(equipment.total_value)
return Math.max(displayTotalValue - ownTotalValue, 0)
}
export const sumSnapshotChildrenValue = (snapshot?: string | null) => {
if (!snapshot) {
return 0
}
try {
const children = JSON.parse(snapshot) as Array<{ unit_price?: unknown }>
return calculateChildrenTotalValueFromRecords(children)
} catch {
return 0
}
}

View File

@ -1,4 +1,6 @@
const EQUIPMENT_CODE_PATTERN = /^[A-Z0-9]{2,20}-\d{4}-\d{5}$/
const CURRENT_EQUIPMENT_CODE_PATTERN = /^JS-[A-Z0-9]{1,20}-\d{4}$/
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const normalizeShortName = (value?: string | null) => {
const normalized = value?.trim().toUpperCase() || ''
@ -25,17 +27,17 @@ const resolveTypeShortName = async (equipmentType: string, prisma: any) => {
/**
* 生成资产编码
* 格式: {类型简称}-{地址ID(4位)}-{序号(5位)}
* 例如: PC-0001-00001
* 当前格式: JS-{类型简称}-{序号(4位)}
* 例如: JS-PC-0001
*/
export const generateEquipmentCode = async (
equipmentType: string,
prisma: any,
storageLocationId?: number | null
_storageLocationId?: number | null
): Promise<string> => {
const typeCode = await resolveTypeShortName(equipmentType, prisma)
const locationCode = String(storageLocationId || 0).padStart(4, '0')
const prefix = `${typeCode}-${locationCode}-`
const prefix = `JS-${typeCode}-`
const sequencePattern = new RegExp(`^${escapeRegExp(prefix)}(\\d{4})$`)
const existingEquipment = await prisma.equipment.findMany({
where: {
@ -55,22 +57,22 @@ export const generateEquipmentCode = async (
let startSequence = 1
if (existingEquipment.length > 0) {
const maxCode = existingEquipment[0].equipment_code
if (maxCode.startsWith(prefix)) {
const sequencePart = maxCode.substring(prefix.length)
const maxSequence = parseInt(sequencePart, 10)
const matched = maxCode.match(sequencePattern)
if (matched) {
const maxSequence = parseInt(matched[1], 10)
if (!Number.isNaN(maxSequence)) {
startSequence = maxSequence + 1
}
}
}
if (startSequence > 99999) {
throw new Error(`资产编码序号已溢出,该类型和地址的资产数量已超过 99999`)
if (startSequence > 9999) {
throw new Error(`资产编码序号已溢出,该类型资产数量已超过 9999`)
}
const maxRetries = 20
for (let attempt = 0; attempt < maxRetries; attempt++) {
const sequence = String(startSequence + attempt).padStart(5, '0')
const sequence = String(startSequence + attempt).padStart(4, '0')
const code = `${prefix}${sequence}`
const existing = await prisma.equipment.findUnique({
@ -86,5 +88,5 @@ export const generateEquipmentCode = async (
}
export const validateEquipmentCode = (code: string): boolean => {
return EQUIPMENT_CODE_PATTERN.test(code.trim().toUpperCase())
return CURRENT_EQUIPMENT_CODE_PATTERN.test(code.trim().toUpperCase())
}

1
certbot/www/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -54,9 +54,11 @@ for container in "${LEGACY_CONTAINERS[@]}"; do
echo " $container => $status"
done
echo ">>> 仅更新固定资产系统容器..."
"${COMPOSE_CMD[@]}" down
echo ">>> 先构建固定资产系统镜像..."
"${COMPOSE_CMD[@]}" build
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
"${COMPOSE_CMD[@]}" down
"${COMPOSE_CMD[@]}" up -d
echo ">>> 等待服务启动..."

View File

@ -45,6 +45,7 @@ services:
SQLSERVER_TRUST_CERT: ${SQLSERVER_TRUST_CERT:-true}
HR_SYNC_ENABLED: ${HR_SYNC_ENABLED:-false}
HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00}
HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3}
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5}
HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流}
@ -54,6 +55,7 @@ services:
condition: service_healthy
volumes:
- backend_uploads:/app/uploads
- ./frontend/public/branding:/app/branding:ro
# 前端服务
frontend:
@ -67,8 +69,13 @@ services:
restart: always
ports:
- "80:80"
- "443:443"
depends_on:
- backend
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot
- ./frontend/public/branding:/usr/share/nginx/html/branding:ro
volumes:
mysql_data:

7
frontend/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
coverage
*.log
.env
.env.*
.git

View File

@ -1,20 +1,30 @@
server {
listen 80;
server_name localhost;
server_name www.jsgdzc.cn jsgdzc.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 80 default_server;
server_name _;
charset utf-8;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# 前端路由支持
location / {
try_files $uri $uri/ /index.html;
}
# API代理
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
@ -26,14 +36,61 @@ server {
proxy_cache_bypass $http_upgrade;
}
# 上传文件代理
location /uploads {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
server {
listen 443 ssl;
http2 on;
server_name www.jsgdzc.cn jsgdzc.cn;
charset utf-8;
root /usr/share/nginx/html;
index index.html;
ssl_certificate /etc/letsencrypt/live/www.jsgdzc.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.jsgdzc.cn/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
location /uploads {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";

View File

@ -16,6 +16,7 @@
"jsbarcode": "^3.12.1",
"keycloak-js": "^23.0.7",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"vue": "^3.5.24",
"vue-router": "^4.6.3",
@ -994,6 +995,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmmirror.com/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
@ -3049,6 +3056,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qr-scanner": {
"version": "1.4.2",
"resolved": "https://registry.npmmirror.com/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",

View File

@ -17,6 +17,7 @@
"jsbarcode": "^3.12.1",
"keycloak-js": "^23.0.7",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"vue": "^3.5.24",
"vue-router": "^4.6.3",

View File

@ -0,0 +1,9 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="18" fill="#101010"/>
<rect x="3" y="3" width="58" height="58" rx="15" stroke="#10A37F" stroke-width="2"/>
<rect x="14" y="14" width="36" height="8" rx="4" fill="#10A37F" fill-opacity="0.2"/>
<rect x="14" y="27" width="18" height="18" rx="6" fill="#10A37F"/>
<rect x="35" y="27" width="14" height="7" rx="3.5" fill="#1D1D1D" stroke="#10A37F" stroke-width="2"/>
<rect x="35" y="38" width="14" height="7" rx="3.5" fill="#1D1D1D" stroke="#10A37F" stroke-width="2"/>
<text x="23" y="40.5" text-anchor="middle" fill="white" font-size="11" font-weight="700" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif">资</text>
</svg>

After

Width:  |  Height:  |  Size: 774 B

View File

@ -0,0 +1,10 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" rx="64" fill="#101010"/>
<rect x="12" y="12" width="232" height="232" rx="52" stroke="#10A37F" stroke-width="8"/>
<rect x="44" y="52" width="168" height="32" rx="16" fill="#10A37F" fill-opacity="0.2"/>
<rect x="44" y="96" width="72" height="72" rx="20" fill="#10A37F"/>
<rect x="128" y="96" width="84" height="28" rx="14" fill="#1D1D1D" stroke="#10A37F" stroke-width="4"/>
<rect x="128" y="140" width="84" height="28" rx="14" fill="#1D1D1D" stroke="#10A37F" stroke-width="4"/>
<text x="80" y="144" text-anchor="middle" fill="white" font-size="42" font-weight="700" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif">资</text>
<text x="128" y="208" text-anchor="middle" fill="#C7D2D9" font-size="28" font-weight="600" font-family="Segoe UI, Arial, sans-serif" letter-spacing="3">ASSET</text>
</svg>

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,5 +1,5 @@
<template>
<div id="app" class="min-h-screen bg-system-background-secondary">
<div id="app" class="min-h-screen bg-bg-primary">
<router-view />
</div>
</template>

View File

@ -24,6 +24,11 @@ export const getEquipmentBatchForBarcode = async (equipmentIds: number[]) => {
equipment_name: string
equipment_model?: string
equipment_type: string
created_at?: string
purchase_date?: string
department?: {
name: string
} | null
storage_location_id?: number | null
storageLocation?: {
id: number
@ -38,4 +43,3 @@ export const getEquipmentBatchForBarcode = async (equipmentIds: number[]) => {
return response.data
}

View File

@ -1,4 +1,5 @@
import request from './index'
import type { ApiResponse } from '@/types'
export interface BatchResult {
success_count: number
@ -19,7 +20,7 @@ export const batchBorrow = (data: {
is_long_term?: boolean
notes?: string
}) => {
return request.post<BatchResult>('/batch/borrow', data)
return request.post<ApiResponse<BatchResult>>('/batch/borrow', data).then((response) => response.data)
}
// 批量归还
@ -28,7 +29,7 @@ export const batchReturn = (data: {
return_condition?: string
notes?: string
}) => {
return request.post<BatchResult>('/batch/return', data)
return request.post<ApiResponse<BatchResult>>('/batch/return', data).then((response) => response.data)
}
// 批量转移部门
@ -36,5 +37,5 @@ export const batchTransfer = (data: {
equipment_ids: number[]
department_id: number
}) => {
return request.post<BatchResult>('/batch/transfer', data)
return request.post<ApiResponse<BatchResult>>('/batch/transfer', data).then((response) => response.data)
}

View File

@ -146,6 +146,7 @@ export const getBorrowRecordList = async (params: {
status?: string
person_name?: string
person_id?: number
keyword?: string
}) => {
const response = await api.get<ApiResponse<PaginatedResponse<BorrowRecord>>>('/borrow/records', {
params

View File

@ -1,7 +1,30 @@
import api from './index'
import type { Equipment, PaginatedResponse, ApiResponse } from '@/types'
const transformEquipmentPayload = (data: Partial<Equipment>) => {
export interface EquipmentBundleChildPayload {
equipment_name: string
equipment_model?: string
equipment_type: string
code_generation_mode?: 'auto' | 'manual'
quantity?: number
unit_price?: number
purchase_date?: string
supplier?: string
description?: string
storage_location_id?: number
department_id?: number
asset_type_id?: number
notes?: string
}
export type EquipmentCreatePayload = Omit<Partial<Equipment>, 'children'> & {
entry_mode?: 'single' | 'parent' | 'child'
children?: EquipmentBundleChildPayload[]
}
export type EquipmentUpdatePayload = Omit<Partial<Equipment>, 'children'>
const transformEquipmentPayload = (data: EquipmentCreatePayload | EquipmentUpdatePayload) => {
const payload: Record<string, any> = { ...data }
if (payload.storageLocation) {
payload.storage_location_id = payload.storageLocation?.id
@ -44,13 +67,13 @@ export const getEquipmentDetail = async (id: number) => {
}
// 新增资产
export const createEquipment = async (data: Partial<Equipment>) => {
export const createEquipment = async (data: EquipmentCreatePayload) => {
const response = await api.post<ApiResponse<Equipment>>('/equipment', transformEquipmentPayload(data))
return response.data
}
// 更新资产
export const updateEquipment = async (id: number, data: Partial<Equipment>) => {
export const updateEquipment = async (id: number, data: EquipmentUpdatePayload) => {
const response = await api.put<ApiResponse<Equipment>>(`/equipment/${id}`, transformEquipmentPayload(data))
return response.data
}
@ -94,4 +117,3 @@ export const scanEquipment = async (equipmentCode: string) => {
const response = await api.get<ApiResponse<Equipment>>(`/equipment/scan/${equipmentCode}`)
return response.data
}

View File

@ -33,6 +33,9 @@ const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return response
}
const data = response.data as ApiResponse
if (data?.code === 200) {
return response
@ -78,4 +81,3 @@ api.interceptors.response.use(
)
export default api

View File

@ -0,0 +1,30 @@
import api from './index'
import type { ApiResponse, NotificationItem, PaginatedResponse } from '@/types'
export interface NotificationListResponse extends PaginatedResponse<NotificationItem> {
unread_count: number
}
export const getNotificationList = async (params?: {
page?: number
pageSize?: number
status?: 'all' | 'unread'
}) => {
const response = await api.get<ApiResponse<NotificationListResponse>>('/notifications', { params })
return response.data
}
export const getUnreadNotificationCount = async () => {
const response = await api.get<ApiResponse<{ count: number }>>('/notifications/unread-count')
return response.data
}
export const markNotificationAsRead = async (id: number) => {
const response = await api.patch<ApiResponse<NotificationItem>>(`/notifications/${id}/read`)
return response.data
}
export const markAllNotificationsAsRead = async () => {
const response = await api.patch<ApiResponse<{ updated: number }>>('/notifications/read-all')
return response.data
}

View File

@ -19,8 +19,7 @@ export const getSiteConfig = async () => {
return response.data
}
export const updateSiteConfig = async (data: { site_name: string; theme_color?: string }) => {
export const updateSiteConfig = async (data: { site_name: string }) => {
const response = await api.put<ApiResponse<SiteConfigResponse>>('/settings/site', data)
return response.data
}

44
frontend/src/api/sync.ts Normal file
View File

@ -0,0 +1,44 @@
import api from './index'
import type {
ApiResponse,
HrSyncChangeItem,
HrSyncJobDetail,
HrSyncJobHistoryResponse,
HrSyncPreview,
HrSyncResult,
HrSyncStatus,
UpdateHrSyncChangeProcessingPayload
} from '@/types'
export const getHrSyncStatus = async () => {
const response = await api.get<ApiResponse<HrSyncStatus>>('/sync/hr/status')
return response.data
}
export const previewHrSync = async () => {
const response = await api.get<ApiResponse<HrSyncPreview>>('/sync/hr/preview')
return response.data
}
export const runHrSync = async () => {
const response = await api.post<ApiResponse<HrSyncResult>>('/sync/hr/run')
return response.data
}
export const getHrSyncJobs = async (params?: {
page?: number
pageSize?: number
}) => {
const response = await api.get<ApiResponse<HrSyncJobHistoryResponse>>('/sync/hr/jobs', { params })
return response.data
}
export const getHrSyncJobDetail = async (id: number) => {
const response = await api.get<ApiResponse<HrSyncJobDetail>>(`/sync/hr/jobs/${id}`)
return response.data
}
export const updateHrSyncChangeProcessing = async (id: number, payload: UpdateHrSyncChangeProcessingPayload) => {
const response = await api.patch<ApiResponse<HrSyncChangeItem>>(`/sync/hr/changes/${id}/processing`, payload)
return response.data
}

View File

@ -1,36 +1,48 @@
<template>
<div class="card-apple">
<h2 class="text-apple-title mb-4">二维码打印</h2>
<div class="card space-y-4">
<div class="list-header">
<div class="space-y-1">
<h2 class="section-title">二维码打印</h2>
<p class="text-sm text-text-secondary">当前预览按 60 × 40 mm、300 DPI 生成。</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="badge badge-neutral">共 {{ equipmentList.length }} 张</span>
<span class="badge badge-neutral">标签 60 × 40</span>
</div>
</div>
<div class="space-y-4">
<div
v-for="equipment in equipmentList"
:key="equipment.equipment_code"
class="qrcode-card"
>
<!-- 顶部公司名称 -->
<div class="qrcode-header">
<span class="header-title">{{ siteName }}</span>
</div>
<!-- 下方内容区 -->
<div class="qrcode-body">
<!-- 左侧信息 -->
<div class="qrcode-info">
<p class="info-row">{{ equipment.assetType?.name || equipment.equipment_type || '-' }}</p>
<p class="info-row">{{ equipment.equipment_code }}</p>
<p class="info-row">{{ formatDate(equipment.created_at) }}</p>
<p class="info-row">{{ equipment.equipment_model || '-' }}</p>
<div class="grid gap-4 lg:grid-cols-2">
<div
v-for="equipment in equipmentList"
:key="equipment.equipment_code"
class="panel space-y-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="text-sm font-medium text-text-primary">{{ equipment.equipment_name }}</p>
<p class="mt-1 text-xs text-text-tertiary">{{ equipment.equipment_type || '未分类资产' }}</p>
</div>
<span class="badge badge-neutral">{{ equipment.equipment_code }}</span>
</div>
<!-- 右侧二维码 -->
<div class="qrcode-canvas">
<canvas :id="`qrcode-${equipment.id}`"></canvas>
<div class="qrcode-card" :style="previewCardStyle">
<img
v-if="previewImages[equipment.id]"
:src="previewImages[equipment.id]"
:alt="`${equipment.equipment_code} 预览`"
class="qrcode-preview-image"
/>
<div v-else class="qrcode-preview-placeholder">生成预览中...</div>
</div>
</div>
</div>
<div class="flex gap-2">
<button @click="handlePrint" class="btn-apple-primary flex-1" :disabled="loading">
<div class="flex flex-col gap-3 sm:flex-row">
<button @click="handlePrint" class="btn-primary flex-1" :disabled="loading">
{{ loading ? '生成中...' : '打印二维码' }}
</button>
<button @click="handleDownload" class="btn-apple-secondary flex-1" :disabled="loading">
<button @click="handleDownload" class="btn-secondary flex-1" :disabled="loading">
{{ loading ? '生成中...' : '下载图片' }}
</button>
</div>
@ -39,10 +51,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { computed, onMounted, ref } from 'vue'
import QRCode from 'qrcode'
import { getEquipmentBatchForBarcode } from '@/api/barcode'
import { useSiteStore } from '@/stores/site'
import QRCode from 'qrcode'
interface EquipmentBarcode {
id: number
@ -51,14 +63,11 @@ interface EquipmentBarcode {
equipment_model?: string
equipment_type: string
created_at?: string
storage_location_id?: number | null
storageLocation?: {
id: number
purchase_date?: string
department?: {
name: string
code?: string | null
} | null
assetType?: {
id: number
storageLocation?: {
name: string
} | null
}
@ -69,66 +78,39 @@ interface Props {
const props = defineProps<Props>()
// 标签纸配置 - 60mm x 40mm 横向,300 DPI
// 60mm = 2.362 英寸 × 300 DPI = 709 像素
// 40mm = 1.575 英寸 × 300 DPI = 472 像素
const LABEL_PRESET = {
widthMm: 60,
heightMm: 40,
previewWidthPx: 360,
previewHeightPx: 240,
canvasWidthPx: 709, // 300 DPI
canvasHeightPx: 472, // 300 DPI
qrSizePx: 260, // 二维码尺寸
paddingPx: 14,
companyName: '常州简尚家具有限公司'
} as const
const siteStore = useSiteStore()
const siteName = ref('固定资产管理系统')
const equipmentList = ref<EquipmentBarcode[]>([])
const previewImages = ref<Record<number, string>>({})
const loading = ref(false)
const EXPORT_WIDTH = 500
const EXPORT_HEIGHT = 300
const EXPORT_PADDING = 12
const QR_SIZE = 176
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const previewCardStyle = computed(() => ({
width: '100%',
maxWidth: `${LABEL_PRESET.previewWidthPx}px`,
aspectRatio: `${LABEL_PRESET.previewWidthPx} / ${LABEL_PRESET.previewHeightPx}`,
background: '#ffffff',
borderRadius: '16px',
overflow: 'hidden',
boxShadow: '0 1px 4px rgba(0,0,0,0.1)'
}))
const fetchEquipment = async () => {
loading.value = true
try {
// 获取站点名称
await siteStore.fetchSiteConfig()
siteName.value = siteStore.siteName || '固定资产管理系统'
const response = await getEquipmentBatchForBarcode(props.equipmentIds)
const data = response.data || response
if (data && Array.isArray(data)) {
equipmentList.value = data
await nextTick()
generateQRCodes()
}
} catch (error) {
console.error('获取资产信息失败:', error)
} finally {
loading.value = false
}
}
const generateQRCodes = async () => {
for (const equipment of equipmentList.value) {
const canvasElement = document.getElementById(`qrcode-${equipment.id}`) as HTMLCanvasElement
if (canvasElement) {
try {
await QRCode.toCanvas(canvasElement, equipment.equipment_code, {
width: 176,
margin: 1,
color: {
dark: '#000000',
light: '#ffffff'
},
errorCorrectionLevel: 'M'
})
} catch (error) {
console.error('二维码生成失败:', equipment.equipment_code, error)
}
}
}
const formatDate = (value?: string) => {
if (!value) return ''
return value.includes('T') ? value.split('T')[0] : value
}
const loadImage = (src: string) =>
@ -139,96 +121,248 @@ const loadImage = (src: string) =>
image.src = src
})
const fitText = (
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number
) => {
const value = String(text || '-')
if (ctx.measureText(value).width <= maxWidth) {
return value
}
const ellipsis = '...'
let result = value
while (result.length > 0 && ctx.measureText(`${result}${ellipsis}`).width > maxWidth) {
result = result.slice(0, -1)
}
return result ? `${result}${ellipsis}` : ellipsis
}
const renderCardToCanvas = async (equipment: EquipmentBarcode) => {
const canvas = document.createElement('canvas')
canvas.width = EXPORT_WIDTH
canvas.height = EXPORT_HEIGHT
canvas.width = LABEL_PRESET.canvasWidthPx
canvas.height = LABEL_PRESET.canvasHeightPx
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建导出画布')
throw new Error('无法创建画布')
}
const { canvasWidthPx, canvasHeightPx, qrSizePx } = LABEL_PRESET
// 纯白背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, EXPORT_WIDTH, EXPORT_HEIGHT)
ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx)
// ═══════════════════════════════════════════════════════
// 60x40mm 标签设计 - 信息丰富 + 大字体 (300 DPI)
// ═══════════════════════════════════════════════════════
const leftSectionWidth = Math.floor(canvasWidthPx * 0.52) // 52% 给二维码
const rightSectionX = leftSectionWidth
const rightSectionWidth = canvasWidthPx - leftSectionWidth
// 左侧纯黑背景
ctx.fillStyle = '#000000'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = '700 24px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText(siteName.value, EXPORT_WIDTH / 2, 38)
ctx.fillRect(0, 0, leftSectionWidth, canvasHeightPx)
ctx.lineWidth = 2
// 斜切装饰 - 右上角白色三角形
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.moveTo(EXPORT_PADDING, 66)
ctx.lineTo(EXPORT_WIDTH - EXPORT_PADDING, 66)
ctx.stroke()
ctx.moveTo(leftSectionWidth - 40, 0)
ctx.lineTo(leftSectionWidth, 0)
ctx.lineTo(leftSectionWidth, 40)
ctx.closePath()
ctx.fill()
const leftX = EXPORT_PADDING + 8
const infoWidth = EXPORT_WIDTH - QR_SIZE - EXPORT_PADDING * 3 - 24
const rowTop = 94
const rowGap = 56
const rows = [
equipment.assetType?.name || equipment.equipment_type || '-',
equipment.equipment_code || '-',
formatDate(equipment.created_at),
equipment.equipment_model || '-'
]
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.font = '500 20px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
for (let i = 0; i < rows.length; i++) {
ctx.fillText(fitText(ctx, rows[i], infoWidth), leftX, rowTop + i * rowGap)
}
// 二维码位置 - 左侧居中
const qrX = Math.floor((leftSectionWidth - qrSizePx) / 2) + 4
const qrY = Math.floor((canvasHeightPx - qrSizePx) / 2)
// 生成二维码
const qrDataUrl = await QRCode.toDataURL(equipment.equipment_code, {
width: QR_SIZE,
margin: 1,
width: qrSizePx,
margin: 0,
color: {
dark: '#000000',
light: '#ffffff'
},
errorCorrectionLevel: 'M'
errorCorrectionLevel: 'H'
})
const qrImage = await loadImage(qrDataUrl)
const qrX = EXPORT_WIDTH - EXPORT_PADDING - QR_SIZE
const qrY = 88
ctx.drawImage(qrImage, qrX, qrY, QR_SIZE, QR_SIZE)
// 二维码白色背景块
ctx.fillStyle = '#ffffff'
ctx.fillRect(qrX - 6, qrY - 6, qrSizePx + 12, qrSizePx + 12)
// 绘制二维码
ctx.drawImage(qrImage, qrX, qrY, qrSizePx, qrSizePx)
// 二维码外框 - 粗黑线
ctx.strokeStyle = '#000000'
ctx.lineWidth = 3
ctx.strokeRect(qrX - 6, qrY - 6, qrSizePx + 12, qrSizePx + 12)
// 四角定位块装饰
const cornerSize = 14
ctx.fillStyle = '#000000'
ctx.fillRect(qrX - 10, qrY - 10, cornerSize, cornerSize)
ctx.fillRect(qrX + qrSizePx - 4, qrY - 10, cornerSize, cornerSize)
ctx.fillRect(qrX - 10, qrY + qrSizePx - 4, cornerSize, cornerSize)
ctx.fillRect(qrX + qrSizePx - 4, qrY + qrSizePx - 4, cornerSize, cornerSize)
// 公司名称竖排 - 左侧边缘
ctx.save()
ctx.translate(10, canvasHeightPx / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillStyle = '#000000'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText('常州简尚家具有限公司', 0, 0)
ctx.restore()
// ═══════════════════════════════════════════════════════
// 右侧信息区域 - 舒适布局,适当留白,行间距加大
// ═══════════════════════════════════════════════════════
const infoX = rightSectionX + 12
const infoY = 18
// 完整公司名称 - 两行分散布局
ctx.fillStyle = '#000000'
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.font = 'bold 29px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText('常州简尚', infoX, infoY)
ctx.font = 'bold 21px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText('家具有限公司', infoX, infoY + 34)
// 粗分隔线
ctx.strokeStyle = '#000000'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(infoX, infoY + 68)
ctx.lineTo(canvasWidthPx - 12, infoY + 68)
ctx.stroke()
// 资产名称 - 最大最突出
ctx.fillStyle = '#000000'
ctx.font = 'bold 41px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
const nameText = equipment.equipment_name || '-'
let displayName = nameText
const maxNameWidth = rightSectionWidth - 24
if (ctx.measureText(nameText).width > maxNameWidth) {
while (ctx.measureText(displayName + '...').width > maxNameWidth && displayName.length > 0) {
displayName = displayName.slice(0, -1)
}
displayName += '...'
}
ctx.fillText(displayName, infoX, infoY + 82)
// 型号 (如果有) - 放大确保打印清晰
let currentY = infoY + 128
if (equipment.equipment_model) {
ctx.fillStyle = '#000000'
ctx.font = 'bold 21px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText(`型号: ${equipment.equipment_model}`, infoX, currentY)
currentY += 32
}
// 编码 - 更大字体
ctx.fillStyle = '#000000'
ctx.font = 'bold 30px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText(equipment.equipment_code || '-', infoX, currentY)
currentY += 38
// 部门 + 存放位置 - 放大
ctx.fillStyle = '#000000'
ctx.font = 'bold 19px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
const deptText = equipment.department?.name || '未分配部门'
const locText = equipment.storageLocation?.name
if (locText) {
ctx.fillText(`${deptText} | ${locText}`, infoX, currentY)
} else {
ctx.fillText(deptText, infoX, currentY)
}
currentY += 30
// 购买日期 - 放大
const purchaseDate = formatDate(equipment.purchase_date)
const createdDate = formatDate(equipment.created_at)
if (purchaseDate || createdDate) {
ctx.fillStyle = '#000000'
ctx.font = 'bold 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
const dateLabel = purchaseDate ? '购买' : '入库'
const dateValue = purchaseDate || createdDate
ctx.fillText(`${dateLabel}: ${dateValue}`, infoX, currentY)
}
// 底部粗黑条 - 类型
const barHeight = 30
const barY = canvasHeightPx - barHeight - 12
ctx.fillStyle = '#000000'
ctx.fillRect(infoX, barY, rightSectionWidth - 24, barHeight)
// 底部条上的类型文字
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.font = 'bold 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
const typeText = (equipment.equipment_type || 'ASSET').toUpperCase()
ctx.fillText(typeText, infoX + 10, barY + barHeight / 2 + 1)
// 右侧边缘装饰线
ctx.strokeStyle = '#000000'
ctx.lineWidth = 5
ctx.beginPath()
ctx.moveTo(canvasWidthPx - 6, 12)
ctx.lineTo(canvasWidthPx - 6, canvasHeightPx - 12)
ctx.stroke()
return canvas
}
const generateImages = async (): Promise<string[]> => {
const images: string[] = []
const setPreviewImages = (images: string[]) => {
const nextPreviewImages: Record<number, string> = {}
equipmentList.value.forEach((equipment, index) => {
if (images[index]) {
nextPreviewImages[equipment.id] = images[index]
}
})
previewImages.value = nextPreviewImages
}
const getCachedImages = () => {
const images = equipmentList.value
.map((equipment) => previewImages.value[equipment.id])
.filter(Boolean)
return images.length === equipmentList.value.length ? images : null
}
const syncPreviewImages = async (): Promise<string[]> => {
if (equipmentList.value.length === 0) {
previewImages.value = {}
return []
}
const images: string[] = []
for (const equipment of equipmentList.value) {
const canvas = await renderCardToCanvas(equipment)
images.push(canvas.toDataURL('image/png', 1.0))
}
setPreviewImages(images)
return images
}
const ensureImagesGenerated = async (): Promise<string[]> => {
return getCachedImages() ?? syncPreviewImages()
}
const fetchEquipment = async () => {
loading.value = true
try {
await siteStore.fetchSiteConfig()
const response = await getEquipmentBatchForBarcode(props.equipmentIds)
const data = response.data || response
if (data && Array.isArray(data)) {
equipmentList.value = data
await syncPreviewImages()
}
} catch (error) {
console.error('获取资产信息失败:', error)
} finally {
loading.value = false
}
}
const handlePrint = async () => {
if (equipmentList.value.length === 0) {
alert('没有可打印的二维码')
@ -237,7 +371,7 @@ const handlePrint = async () => {
loading.value = true
try {
const images = await generateImages()
const images = await ensureImagesGenerated()
const printWindow = window.open('', '_blank')
if (!printWindow) {
@ -249,21 +383,27 @@ const handlePrint = async () => {
<!DOCTYPE html>
<html>
<head>
<title>打印二维码</title>
<title>二维码打印</title>
<style>
@page {
size: 5cm 3cm;
size: ${LABEL_PRESET.widthMm}mm ${LABEL_PRESET.heightMm}mm;
margin: 0;
}
body {
html, body {
margin: 0;
padding: 0;
background: white;
width: ${LABEL_PRESET.widthMm}mm;
height: ${LABEL_PRESET.heightMm}mm;
background: #ffffff;
overflow: hidden;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.qrcode-image {
width: 5cm;
height: 3cm;
width: ${LABEL_PRESET.widthMm}mm;
height: ${LABEL_PRESET.heightMm}mm;
display: block;
object-fit: contain;
page-break-after: always;
page-break-inside: avoid;
}
@ -273,7 +413,7 @@ const handlePrint = async () => {
</style>
</head>
<body>
${images.map(img => `<img src="${img}" class="qrcode-image" />`).join('')}
${images.map((img) => `<img src="${img}" class="qrcode-image" />`).join('')}
</body>
</html>
`
@ -303,16 +443,16 @@ const handleDownload = async () => {
loading.value = true
try {
const images = await generateImages()
const images = await ensureImagesGenerated()
for (let i = 0; i < images.length; i++) {
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, 300))
await new Promise((resolve) => setTimeout(resolve, 300))
}
const equipment = equipmentList.value[i]
const fileName = `${equipment.equipment_code || `qrcode-${i + 1}`}.png`
const link = document.createElement('a')
link.href = images[i]
link.download = fileName
@ -335,91 +475,25 @@ onMounted(() => {
<style scoped>
.qrcode-card {
width: 500px;
height: 300px;
background-color: #ffffff;
border-radius: 0;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
border-radius: 12px;
padding: 12px;
box-sizing: border-box;
position: relative;
margin: 0 auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
/* 顶部公司名称 */
.qrcode-header {
.qrcode-preview-image {
width: 100%;
min-height: 54px;
padding: 8px 6px 10px;
border-bottom: 2px solid #000;
box-sizing: border-box;
height: 100%;
object-fit: contain;
}
.header-title {
font-size: 24px;
line-height: 1.3;
font-weight: 700;
color: #000;
display: block;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 下方内容区 */
.qrcode-body {
flex: 1;
display: flex;
flex-direction: row;
gap: 16px;
min-height: 0;
align-items: stretch;
}
/* 左侧信息区 */
.qrcode-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4px 0;
}
.info-row {
margin: 0;
min-height: 28px;
font-size: 20px;
font-weight: 500;
color: #000;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 右侧二维码 */
.qrcode-canvas {
flex-shrink: 0;
width: 176px;
height: 176px;
.qrcode-preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
align-self: center;
}
.qrcode-canvas canvas {
width: 100% !important;
height: 100% !important;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
color: var(--text-tertiary);
font-size: 14px;
}
</style>

View File

@ -0,0 +1,376 @@
<template>
<Teleport to="body">
<div
v-if="modelValue"
class="sheet-backdrop z-[80]"
@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>
<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" />
</svg>
</button>
</div>
</div>
<div class="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 sm:px-5 sm:py-5">
<div class="space-y-4">
<div class="relative overflow-hidden rounded-2xl border border-border bg-black">
<video
ref="videoRef"
class="h-[50vh] min-h-[320px] w-full object-cover sm:h-[60vh] sm:min-h-[400px]"
autoplay
playsinline
muted
/>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-56 w-56 rounded-3xl border-2 border-white/60 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)] sm:h-64 sm:w-64"></div>
</div>
<div
v-if="starting"
class="absolute inset-0 flex items-center justify-center bg-black/60 px-6 text-center text-sm text-white"
>
正在打开摄像头...
</div>
<div
v-else-if="cameraError"
class="absolute inset-0 flex items-center justify-center bg-black/70 px-6 text-center"
>
<div class="max-w-sm space-y-3">
<p class="text-sm leading-6 text-white">{{ cameraError }}</p>
<button type="button" class="btn-primary w-full sm:w-auto" @click="restartScanner">
重新尝试
</button>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<button type="button" class="btn-secondary w-full" @click="toggleCamera">
切换摄像头
</button>
<button
type="button"
class="btn-secondary w-full"
:disabled="!flashAvailable"
@click="toggleFlashMode"
>
{{ flashOn ? '关闭补光' : '打开补光' }}
</button>
<label class="btn-secondary w-full cursor-pointer">
从图片识别
<input
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="handleFileChange"
/>
</label>
<button type="button" class="btn-secondary w-full" @click="close">
稍后再扫
</button>
</div>
<div v-if="flashHint" class="rounded-xl border border-border bg-bg-elevated px-4 py-3 text-sm text-text-secondary">
{{ flashHint }}
</div>
<div v-if="imageError" class="rounded-xl border border-status-error/30 bg-status-error/10 px-4 py-3 text-sm text-status-error">
{{ imageError }}
</div>
<div class="panel">
<label class="mb-2 block text-sm font-medium text-text-primary">手动输入</label>
<div class="flex flex-col gap-3 sm:flex-row">
<input
v-model.trim="manualValue"
type="text"
class="input flex-1"
:placeholder="manualPlaceholder"
@keyup.enter="submitManual"
/>
<button type="button" class="btn-primary shrink-0 sm:min-w-[96px]" @click="submitManual">
填入
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import QrScanner from 'qr-scanner'
const props = withDefaults(
defineProps<{
modelValue: boolean
title?: string
description?: string
manualPlaceholder?: string
}>(),
{
title: '扫码识别',
description: '将二维码放入取景框,识别成功后会自动填入。',
manualPlaceholder: '请输入或粘贴二维码内容'
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'detected', value: string): void
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const scanner = ref<QrScanner | null>(null)
const manualValue = ref('')
const starting = ref(false)
const cameraError = ref('')
const imageError = ref('')
const preferredCameraMode = ref<'environment' | 'user'>('environment')
const flashAvailable = ref(false)
const flashOn = ref(false)
const flashHint = ref('')
const close = () => {
emit('update:modelValue', false)
}
const normalizeValue = (value: string) => value.trim()
const stopMediaStream = (stream?: MediaStream | null) => {
stream?.getTracks().forEach((track) => track.stop())
}
const buildCameraMatchPatterns = (mode: 'environment' | 'user') => {
if (mode === 'environment') {
return [/rear/i, /back/i, /environment/i, /后置/, /背面/, /外置/]
}
return [/front/i, /user/i, /face/i, /前置/, /自拍/, /内置/]
}
const resolvePreferredCamera = async () => {
try {
const cameras = await QrScanner.listCameras(true).catch(() => QrScanner.listCameras())
if (!cameras.length) {
throw new DOMException('没有找到可用摄像头。', 'NotFoundError')
}
const patterns = buildCameraMatchPatterns(preferredCameraMode.value)
const matchedCamera = cameras.find((camera) =>
patterns.some((pattern) => pattern.test(camera.label))
)
return matchedCamera?.id || cameras[0].id
} catch (error) {
if (error instanceof DOMException) {
throw error
}
return preferredCameraMode.value
}
}
const warmUpCameraPermission = async () => {
let previewStream: MediaStream | null = null
try {
previewStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: preferredCameraMode.value },
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
} finally {
stopMediaStream(previewStream)
}
}
const buildCameraErrorMessage = (error: unknown) => {
if (!window.isSecureContext) {
return '当前页面不是 HTTPS,手机浏览器通常不能直接调用摄像头。'
}
if (error instanceof DOMException) {
const errorMap: Record<string, string> = {
NotAllowedError: '没有获得摄像头权限,请在浏览器中允许访问摄像头。',
NotFoundError: '没有找到可用摄像头。',
NotReadableError: '摄像头正被其他程序占用,请关闭后重试。',
OverconstrainedError: '当前摄像头无法使用这个模式,请切换后重试。',
AbortError: '摄像头启动被中断,请重新尝试。',
SecurityError: '当前浏览器限制了摄像头访问,请改用系统浏览器或 Chrome 打开。',
NotSupportedError: '当前浏览器不支持网页扫码,请改用系统浏览器或 Chrome 打开。'
}
return errorMap[error.name] || '打开摄像头失败,请稍后重试。'
}
if (typeof error === 'string') {
if (error === 'Camera not found.') {
return '浏览器没有成功打开摄像头,请确认已允许摄像头权限,并尽量使用系统浏览器或 Chrome 打开。'
}
return error
}
if (error instanceof Error && error.message) {
return error.message
}
return '打开摄像头失败,请稍后重试。'
}
const getUnavailableCameraMessage = () => {
if (!window.isSecureContext) {
return '当前页面不是 HTTPS,安卓手机浏览器通常不能直接调用摄像头。请先切到 HTTPS 域名后再扫码。'
}
if (!navigator.mediaDevices) {
return '当前浏览器环境不支持网页摄像头,请尽量使用系统浏览器或 Chrome 打开。'
}
if (!navigator.mediaDevices.getUserMedia) {
return '当前浏览器不支持网页摄像头接口,请尽量使用系统浏览器或 Chrome 打开。'
}
return ''
}
const stopScanner = () => {
if (!scanner.value) return
scanner.value.stop()
scanner.value.destroy()
scanner.value = null
flashAvailable.value = false
flashOn.value = false
flashHint.value = ''
}
const handleDetected = (value: string) => {
const normalized = normalizeValue(value)
if (!normalized) return
stopScanner()
emit('detected', normalized)
close()
}
const startScanner = async () => {
if (!props.modelValue) return
cameraError.value = ''
imageError.value = ''
starting.value = true
try {
const unavailableMessage = getUnavailableCameraMessage()
if (unavailableMessage) {
cameraError.value = unavailableMessage
return
}
await nextTick()
if (!videoRef.value) return
await warmUpCameraPermission()
const preferredCamera = await resolvePreferredCamera()
stopScanner()
scanner.value = new QrScanner(
videoRef.value,
(result) => handleDetected(result.data),
{
preferredCamera,
maxScansPerSecond: 10,
returnDetailedScanResult: true,
onDecodeError: () => {}
}
)
await scanner.value.start()
flashAvailable.value = await scanner.value.hasFlash().catch(() => false)
flashHint.value = flashAvailable.value ? '夜间或背光环境可打开补光。' : '当前摄像头不支持网页补光。'
} catch (error) {
cameraError.value = buildCameraErrorMessage(error)
stopScanner()
} finally {
starting.value = false
}
}
const restartScanner = async () => {
stopScanner()
await startScanner()
}
const toggleCamera = async () => {
preferredCameraMode.value = preferredCameraMode.value === 'environment' ? 'user' : 'environment'
await restartScanner()
}
const toggleFlashMode = async () => {
if (!scanner.value || !flashAvailable.value) return
try {
await scanner.value.toggleFlash()
flashOn.value = scanner.value.isFlashOn()
} catch {
flashHint.value = '补光切换失败,请尝试切换摄像头后重试。'
}
}
const handleFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
imageError.value = ''
try {
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
handleDetected(result.data)
} catch {
imageError.value = '没有识别到二维码,请换一张更清晰的图片再试。'
}
}
const submitManual = () => {
if (!manualValue.value.trim()) return
handleDetected(manualValue.value)
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
await nextTick()
await startScanner()
return
}
manualValue.value = ''
cameraError.value = ''
imageError.value = ''
stopScanner()
}
)
onBeforeUnmount(() => {
stopScanner()
})
</script>

View File

@ -1,50 +1,50 @@
<template>
<div class="relative w-full">
<div class="relative">
<MagnifyingGlassIcon class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-system-label-tertiary" />
<MagnifyingGlassIcon class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary" />
<input
v-model="keyword"
type="text"
placeholder="搜索资产编码 / 名称 / 型号"
class="input-apple w-full min-w-[220px] pl-9 pr-4 py-2.5 text-sm"
class="input w-full min-w-[220px] pl-9 pr-4"
@focus="showResults = true"
@blur="handleBlur"
@input="handleSearch"
@keydown.esc="showResults = false"
/>
</div>
<!-- 搜索结果下拉 -->
<div
v-if="showResults && (results.length > 0 || loading)"
class="absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-[18px] border border-[color:var(--border-soft)] bg-white/95 shadow-[0_18px_36px_rgba(35,28,20,0.14)] backdrop-blur"
v-if="showResults && (keyword.trim() || loading)"
class="absolute left-0 right-0 top-full z-50 mt-2 max-h-[min(24rem,calc(100vh-8rem))] overflow-hidden rounded-xl border border-border bg-bg-secondary/95 shadow-[0_18px_44px_rgba(0,0,0,0.36)] backdrop-blur"
>
<div v-if="loading" class="p-4 text-center text-system-label-secondary">
<div v-if="loading" class="p-4 text-center text-sm text-text-secondary">
搜索中...
</div>
<div v-else>
<div v-else-if="results.length > 0">
<div
v-for="item in results"
:key="item.id"
class="cursor-pointer border-b border-[color:var(--border-soft)] px-4 py-3 transition-colors last:border-b-0 hover:bg-[#fbf3ea]"
class="cursor-pointer border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-bg-hover"
@mousedown.prevent="goToDetail(item)"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-medium text-system-label-primary">{{ item.name }}</div>
<span class="chip-muted !px-2 !py-0.5 !text-[11px]">资产</span>
<div class="text-sm font-medium text-text-primary">{{ item.name }}</div>
<span class="badge badge-neutral">资产</span>
</div>
<div class="mt-1 text-xs text-system-label-secondary">{{ item.code }} · {{ item.type }}</div>
<div class="mt-1 text-xs text-text-secondary">{{ item.code }} · {{ item.type }}</div>
</div>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="getStatusClass(item.status)"
>
<span class="badge" :class="getStatusClass(item.status)">
{{ item.status }}
</span>
</div>
</div>
</div>
<div v-else class="p-4 text-center text-sm text-text-tertiary">
没有找到匹配的资产
</div>
</div>
</div>
</template>
@ -66,12 +66,14 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
showResults.value = true
if (!keyword.value.trim()) {
results.value = []
return
}
searchTimer = setTimeout(async () => {
loading.value = true
try {
@ -109,12 +111,12 @@ const goToDetail = (item: SearchResult) => {
const getStatusClass = (status: string) => {
const map: Record<string, string> = {
'可用': 'bg-system-green/10 text-system-green',
'已借出': 'bg-system-orange/10 text-system-orange',
'维护中': 'bg-system-blue/10 text-system-blue',
'待报废': 'bg-system-yellow/10 text-system-yellow',
'已报废': 'bg-system-red/10 text-system-red'
'可用': 'badge-success',
'已借出': 'badge-warning',
'维护中': 'badge-neutral',
'待报废': 'badge-warning',
'已报废': 'badge-error'
}
return map[status] || 'bg-system-gray-5 text-system-label-secondary'
return map[status] || 'badge-neutral'
}
</script>

View File

@ -0,0 +1,22 @@
<template>
<span class="group relative inline-flex align-middle">
<button
type="button"
class="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border bg-bg-elevated text-[10px] font-semibold leading-none text-text-tertiary transition hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-primary/40"
:aria-label="content"
>
!
</button>
<span
class="pointer-events-none absolute left-1/2 top-full z-30 mt-2 hidden w-[240px] -translate-x-1/2 rounded-xl border border-border bg-bg-secondary px-3 py-2 text-left text-xs leading-5 text-text-secondary shadow-[0_18px_44px_rgba(0,0,0,0.36)] group-hover:block group-focus-within:block"
>
{{ content }}
</span>
</span>
</template>
<script setup lang="ts">
defineProps<{
content: string
}>()
</script>

View File

@ -1,53 +1,53 @@
<template>
<div class="space-y-4">
<h3 class="text-apple-headline">挂载历史</h3>
<div v-if="loading" class="text-center py-8 text-system-label-tertiary">
<div class="flex items-center justify-between gap-3">
<h3 class="section-title">挂载历史</h3>
<span v-if="history.length > 0" class="text-xs text-text-tertiary">{{ history.length }} 条记录</span>
</div>
<div v-if="loading" class="panel py-8 text-center text-sm text-text-tertiary">
加载中...
</div>
<div v-else-if="history.length === 0" class="text-center py-8 text-system-label-tertiary">
<div v-else-if="history.length === 0" class="panel py-8 text-center text-sm text-text-tertiary">
暂无挂载历史记录
</div>
<div v-else class="space-y-2">
<div v-else class="space-y-3">
<div
v-for="record in history"
:key="record.id"
class="p-3 bg-system-gray-6 rounded-apple"
class="panel"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 rounded text-apple-footnote font-medium"
:class="getOperationClass(record.operation_type)"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="badge" :class="getOperationClass(record.operation_type)">
{{ getOperationLabel(record.operation_type) }}
</span>
<span class="text-apple-footnote text-system-label-secondary">
<span class="text-xs text-text-tertiary">
{{ formatDateTime(record.operated_at) }}
</span>
</div>
<div class="mt-1 text-apple-body">
<div class="text-sm leading-6 text-text-secondary">
<template v-if="record.operation_type === 'mount'">
<span class="font-medium">{{ record.child?.equipment_name }}</span>
<span class="font-medium text-text-primary">{{ record.child?.equipment_name }}</span>
挂载到
<span class="font-medium">{{ record.parent?.equipment_name || '父资产' }}</span>
<span class="font-medium text-text-primary">{{ record.parent?.equipment_name || '父资产' }}</span>
</template>
<template v-else-if="record.operation_type === 'unmount'">
<span class="font-medium">{{ record.child?.equipment_name }}</span>
<span class="font-medium text-text-primary">{{ record.child?.equipment_name }}</span>
从父资产卸载
</template>
<template v-else-if="record.operation_type === 'replace'">
配件更换:<span class="font-medium">{{ record.child?.equipment_name }}</span>
配件更换:<span class="font-medium text-text-primary">{{ record.child?.equipment_name }}</span>
</template>
</div>
<div v-if="record.notes" class="mt-1 text-apple-footnote text-system-label-secondary">
<div v-if="record.notes" class="text-xs leading-5 text-text-tertiary">
备注:{{ record.notes }}
</div>
</div>
<div class="text-apple-footnote text-system-label-secondary">
<div class="text-xs text-text-tertiary">
{{ record.operator?.name || record.operator?.username || '-' }}
</div>
</div>
@ -91,11 +91,11 @@ const getOperationLabel = (type: string) => {
const getOperationClass = (type: string) => {
const classes: Record<string, string> = {
mount: 'bg-apple-green/15 text-apple-green',
unmount: 'bg-apple-orange/15 text-apple-orange',
replace: 'bg-apple-blue/15 text-apple-blue'
mount: 'badge-success',
unmount: 'badge-warning',
replace: 'badge-neutral'
}
return classes[type] || 'bg-system-gray-5 text-system-label-secondary'
return classes[type] || 'badge-neutral'
}
const fetchHistory = async () => {

View File

@ -1,104 +1,145 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-apple-headline">已挂载配件</h3>
<button class="btn-apple-primary text-sm" @click="showMountModal = true">+ 挂载配件</button>
<div class="list-header">
<div class="space-y-1">
<h3 class="section-title">已挂载配件</h3>
<p class="text-sm text-text-secondary">当前已关联 {{ children.length }} 个配件。</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-neutral">已挂载 {{ children.length }} 个</span>
<button class="btn-primary text-sm" @click="openMountModal">挂载配件</button>
</div>
</div>
<div v-if="children.length === 0" class="text-center py-8 text-system-label-tertiary">
<div v-if="children.length === 0" class="panel py-8 text-center text-sm text-text-tertiary">
暂无挂载的配件
</div>
<div v-else class="space-y-2">
<div v-else class="space-y-3">
<div
v-for="child in children"
:key="child.id"
class="flex items-center justify-between p-3 bg-system-gray-6 rounded-apple"
class="panel"
>
<div class="flex-1">
<div class="font-medium">{{ child.equipment_name }}</div>
<div class="text-apple-footnote text-system-label-secondary">
{{ child.equipment_code }} · {{ child.equipment_type }} · ¥{{ child.unit_price }}
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div class="text-base font-semibold text-text-primary">{{ child.equipment_name }}</div>
<span class="badge badge-neutral">{{ child.equipment_type }}</span>
</div>
<div class="text-sm text-text-secondary">
{{ child.equipment_code }} · ¥{{ formatPrice(child.unit_price) }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="btn-secondary text-sm"
@click="handleReplace(child)"
>
更换
</button>
<button
class="btn-secondary text-sm text-status-error"
@click="handleUnmount(child)"
>
卸载
</button>
</div>
</div>
<div class="flex gap-2">
<button class="text-apple-orange text-sm hover:underline" @click="handleReplace(child)">更换</button>
<button class="text-apple-red text-sm hover:underline" @click="handleUnmount(child)">卸载</button>
</div>
</div>
</div>
<div v-if="children.length > 0" class="pt-2 border-t border-system-gray-5 text-apple-footnote text-system-label-secondary">
配件总价值:¥{{ totalChildrenValue.toFixed(2) }} ·
含配件总价值:¥{{ totalValueWithChildren.toFixed(2) }}
<div
v-if="children.length > 0"
class="panel flex flex-col gap-1 text-sm text-text-secondary sm:flex-row sm:items-center sm:justify-between"
>
<span>配件总价值:¥{{ totalChildrenValue.toFixed(2) }}</span>
<span class="font-medium text-text-primary">含配件总价值:¥{{ totalValueWithChildren.toFixed(2) }}</span>
</div>
<!-- 挂载配件弹窗 -->
<div v-if="showMountModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl max-h-[80vh] overflow-y-auto">
<h3 class="text-apple-title mb-4">选择要挂载的配件</h3>
<input
v-model="searchKeyword"
class="input-apple mb-4"
type="text"
placeholder="搜索配件名称或编码"
@input="fetchAvailableChildren"
/>
<div v-if="availableChildren.length === 0" class="text-center py-8 text-system-label-tertiary">
<div v-if="showMountModal" class="sheet-backdrop">
<div class="sheet-panel max-w-3xl">
<div class="form-panel__header">
<h3 class="form-panel__title">选择要挂载的配件</h3>
<p class="form-panel__desc">只能挂载当前可用且没有未归还记录的配件资产。</p>
</div>
<div class="field-stack">
<label class="field-label">搜索配件</label>
<input
v-model="searchKeyword"
class="input"
type="text"
placeholder="搜索配件名称或编码"
@input="fetchAvailableChildren"
/>
</div>
<div v-if="availableChildren.length === 0" class="panel py-8 text-center text-sm text-text-tertiary">
没有可挂载的配件
</div>
<div v-else class="space-y-2 max-h-60 overflow-y-auto">
<div v-else class="mobile-card-list max-h-80 overflow-y-auto">
<div
v-for="item in availableChildren"
:key="item.id"
class="flex items-center justify-between p-3 bg-system-gray-6 rounded-apple cursor-pointer hover:bg-system-gray-5"
class="mobile-card cursor-pointer transition hover:border-accent-primary/40 hover:bg-bg-hover"
@click="selectChild(item)"
>
<div>
<div class="font-medium">{{ item.equipment_name }}</div>
<div class="text-apple-footnote text-system-label-secondary">
{{ item.equipment_code }} · {{ item.equipment_type }} · ¥{{ item.unit_price }}
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div class="font-medium text-text-primary">{{ item.equipment_name }}</div>
<span class="badge badge-neutral">{{ item.equipment_type }}</span>
</div>
<div class="text-sm text-text-secondary">
{{ item.equipment_code }} · ¥{{ formatPrice(item.unit_price) }}
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-3">
<button class="btn-apple-secondary" @click="showMountModal = false">取消</button>
<button class="btn-secondary" @click="showMountModal = false">取消</button>
</div>
</div>
</div>
<!-- 更换配件弹窗 -->
<div v-if="showReplaceModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl max-h-[80vh] overflow-y-auto">
<h3 class="text-apple-title mb-4">更换配件:{{ replacingChild?.equipment_name }}</h3>
<input
v-model="searchKeyword"
class="input-apple mb-4"
type="text"
placeholder="搜索新配件"
@input="fetchAvailableChildren"
/>
<div v-if="availableChildren.length === 0" class="text-center py-8 text-system-label-tertiary">
<div v-if="showReplaceModal" class="sheet-backdrop">
<div class="sheet-panel max-w-3xl">
<div class="form-panel__header">
<h3 class="form-panel__title">更换配件:{{ replacingChild?.equipment_name }}</h3>
<p class="form-panel__desc">新配件需要同样处于可用状态,且不能已经挂载到别的父资产上。</p>
</div>
<div class="field-stack">
<label class="field-label">搜索新配件</label>
<input
v-model="searchKeyword"
class="input"
type="text"
placeholder="搜索新配件"
@input="fetchAvailableChildren"
/>
</div>
<div v-if="availableChildren.length === 0" class="panel py-8 text-center text-sm text-text-tertiary">
没有可用的配件
</div>
<div v-else class="space-y-2 max-h-60 overflow-y-auto">
<div v-else class="mobile-card-list max-h-80 overflow-y-auto">
<div
v-for="item in availableChildren"
:key="item.id"
class="flex items-center justify-between p-3 bg-system-gray-6 rounded-apple cursor-pointer hover:bg-system-gray-5"
class="mobile-card cursor-pointer transition hover:border-accent-primary/40 hover:bg-bg-hover"
@click="confirmReplace(item)"
>
<div>
<div class="font-medium">{{ item.equipment_name }}</div>
<div class="text-apple-footnote text-system-label-secondary">
{{ item.equipment_code }} · {{ item.equipment_type }} · ¥{{ item.unit_price }}
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div class="font-medium text-text-primary">{{ item.equipment_name }}</div>
<span class="badge badge-neutral">{{ item.equipment_type }}</span>
</div>
<div class="text-sm text-text-secondary">
{{ item.equipment_code }} · ¥{{ formatPrice(item.unit_price) }}
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-3">
<button class="btn-apple-secondary" @click="showReplaceModal = false">取消</button>
<button class="btn-secondary" @click="showReplaceModal = false">取消</button>
</div>
</div>
</div>
@ -127,16 +168,18 @@ const showReplaceModal = ref(false)
const searchKeyword = ref('')
const replacingChild = ref<ChildEquipment | null>(null)
const formatPrice = (value: number | string | null | undefined) => {
const amount = Number(value || 0)
return amount.toFixed(2)
}
const fetchChildren = async () => {
try {
console.log('fetchChildren called, parentId:', props.parentId)
const response = await getChildren(props.parentId)
console.log('getChildren response:', response)
if (response.data) {
children.value = response.data.children || []
totalChildrenValue.value = Number(response.data.total_children_value) || 0
totalValueWithChildren.value = Number(response.data.total_value_with_children) || 0
console.log('children updated:', children.value)
}
} catch (error) {
console.error('获取子资产失败:', error)
@ -154,6 +197,12 @@ const fetchAvailableChildren = async () => {
}
}
const openMountModal = async () => {
searchKeyword.value = ''
showMountModal.value = true
await fetchAvailableChildren()
}
const selectChild = async (child: ChildEquipment) => {
if (!confirm(`确定要挂载 "${child.equipment_name}" 吗?`)) return
try {

View File

@ -1,32 +1,32 @@
<template>
<div class="card-apple h-full">
<div class="card h-full">
<div class="flex items-start justify-between gap-3">
<div
class="flex h-11 w-11 items-center justify-center rounded-[16px] border border-white/70 shadow-sm"
:style="badgeStyle"
class="w-10 h-10 rounded-lg flex items-center justify-center"
:style="{ background: iconBgColor }"
>
<span v-if="iconString" class="text-2xl">{{ iconString }}</span>
<span v-if="iconString" class="text-xl">{{ iconString }}</span>
<component
v-else-if="iconComponent"
:is="iconComponent"
class="h-5 w-5"
class="w-5 h-5"
:class="iconColorClass"
/>
</div>
<span
v-if="trend"
class="rounded-full px-2.5 py-1 text-xs font-medium"
:class="trend > 0 ? 'bg-apple-green text-white' : 'bg-apple-red text-white'"
v-if="trend !== null"
class="badge"
:class="trend > 0 ? 'badge-success' : 'badge-error'"
>
{{ trend > 0 ? '+' : '' }}{{ trend }}%
</span>
</div>
<p class="mt-4 text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">
<p class="mt-4 text-xs font-medium uppercase tracking-wider text-text-tertiary">
{{ title }}
</p>
<p class="stat-value mt-2" :class="getColorClass()">{{ value }}</p>
<p v-if="subtitle" class="mt-2 text-sm leading-6 text-system-label-secondary">
<p class="mt-1 text-2xl font-semibold text-text-primary">{{ value }}</p>
<p v-if="subtitle" class="mt-1 text-sm text-text-secondary">
{{ subtitle }}
</p>
</div>
@ -40,13 +40,13 @@ interface Props {
title: string
value: string | number
icon?: string | Component
color?: string
color?: 'success' | 'warning' | 'error' | 'neutral' | 'primary'
trend?: number | null
subtitle?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'apple-blue',
color: 'primary',
trend: null
})
@ -59,36 +59,13 @@ const iconComponent = computed<Component | null>(() =>
)
const colorMap: Record<string, { text: string; bg: string }> = {
'apple-blue': { text: 'text-apple-blue', bg: 'rgba(0, 122, 255, 0.12)' },
'apple-green': { text: 'text-apple-green', bg: 'rgba(52, 199, 89, 0.12)' },
'apple-orange': { text: 'text-apple-orange', bg: 'rgba(255, 149, 0, 0.14)' },
'apple-yellow': { text: 'text-apple-yellow', bg: 'rgba(255, 204, 0, 0.16)' },
'apple-red': { text: 'text-apple-red', bg: 'rgba(255, 59, 48, 0.12)' }
primary: { text: 'text-accent', bg: 'rgba(16, 163, 127, 0.15)' },
success: { text: 'text-status-success', bg: 'rgba(16, 163, 127, 0.15)' },
warning: { text: 'text-status-warning', bg: 'rgba(245, 158, 11, 0.15)' },
error: { text: 'text-status-error', bg: 'rgba(239, 68, 68, 0.15)' },
neutral: { text: 'text-text-secondary', bg: 'rgba(107, 114, 128, 0.15)' }
}
const iconColorClass = computed(() => colorMap[props.color]?.text || 'text-system-label-primary')
const badgeStyle = computed(() => ({
background: colorMap[props.color]?.bg || 'rgba(61, 70, 86, 0.08)'
}))
const getColorClass = () => {
const textColorMap: Record<string, string> = {
'apple-blue': 'text-apple-blue',
'apple-green': 'text-apple-green',
'apple-orange': 'text-apple-orange',
'apple-yellow': 'text-apple-yellow',
'apple-red': 'text-apple-red'
}
return textColorMap[props.color] || 'text-system-label-primary'
}
const iconColorClass = computed(() => colorMap[props.color]?.text || 'text-text-primary')
const iconBgColor = computed(() => colorMap[props.color]?.bg || 'rgba(107, 114, 128, 0.15)')
</script>
<style scoped>
.stat-value {
font-size: clamp(1.75rem, 1.5rem + 1vw, 2.375rem);
line-height: 1.1;
word-break: break-word;
overflow-wrap: anywhere;
}
</style>

View File

@ -1,120 +1,163 @@
<template>
<div class="app-shell">
<header class="nav-apple sticky top-0 z-50">
<div class="mx-auto flex max-w-[1600px] flex-col gap-4 px-4 py-4 sm:px-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="flex min-w-0 items-center gap-4">
<div class="flex h-14 w-14 items-center justify-center overflow-hidden rounded-[18px] border border-white/50 bg-white/70 shadow-sm">
<img
v-if="siteStore.logoUrl"
:src="siteStore.logoUrl"
alt="logo"
class="h-full w-full object-cover"
/>
<span v-else class="text-xl font-bold text-[color:var(--theme-primary)]">资</span>
<div class="min-h-screen bg-bg-primary">
<div class="flex min-h-screen">
<!-- 移动端遮罩 -->
<div
v-if="mobileNavOpen"
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
@click="mobileNavOpen = false"
></div>
<!-- 侧边栏 -->
<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="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">
<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">
<p class="page-kicker text-[color:var(--ink-faint)]">{{ currentSection }}</p>
<div class="mt-1 flex flex-wrap items-center gap-3">
<h1 class="text-[1.55rem] font-semibold tracking-tight text-system-label-primary">
{{ siteStore.siteName }}
</h1>
<span class="chip-muted">
{{ authStore.user?.role === 'admin' ? '管理员工作台' : '使用者工作台' }}
</span>
</div>
<p class="mt-1 text-sm text-system-label-secondary">
固定资产、借还、维修、报废与统计分析统一工作台
<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>
</div>
</div>
<button type="button" class="btn-icon lg:hidden" @click="mobileNavOpen = false" aria-label="关闭导航">
<XMarkIcon class="h-5 w-5" />
</button>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<router-link
v-for="item in visibleNavItems"
:key="item.path"
:to="item.path"
class="nav-link"
:class="{ active: isActive(item.path) }"
@click="mobileNavOpen = false"
>
<component :is="item.icon" class="h-5 w-5" />
<span>{{ item.title }}</span>
</router-link>
</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">
<span class="text-sm text-text-secondary">{{ userInitials }}</span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-text-primary">
{{ authStore.user?.name || authStore.user?.username || '用户' }}
</p>
<p class="text-xs text-text-tertiary">
{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}
</p>
</div>
</div>
<div class="flex flex-col gap-3 xl:min-w-[440px] xl:items-end">
<GlobalSearch class="w-full xl:w-[360px]" />
<div class="flex flex-wrap items-center gap-3">
<div class="rounded-[18px] border border-[color:var(--border-soft)] bg-white/80 px-4 py-2 shadow-sm">
<p class="text-[11px] uppercase tracking-[0.18em] text-system-label-tertiary">当前用户</p>
<p class="text-sm font-medium text-system-label-primary">
{{ authStore.user?.name || authStore.user?.username || '未命名用户' }}
</p>
</div>
<button @click="handleLogout" class="btn-apple-secondary text-sm">
退出登录
</button>
</div>
</div>
</div>
<nav class="flex gap-2 overflow-x-auto pb-1 lg:hidden">
<router-link
v-for="item in visibleNavItems"
:key="item.path"
:to="item.path"
class="app-nav-link min-w-fit"
:class="{ 'app-nav-link--active': isActive(item.path) }"
>
<component :is="item.icon" class="app-nav-link__icon h-4 w-4" />
<span>{{ item.title }}</span>
</router-link>
</nav>
</div>
</header>
<div class="app-frame">
<aside class="app-sidebar hidden lg:block">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-system-label-tertiary">
工作区导航
</p>
<nav class="mt-4 space-y-2">
<router-link
v-for="item in visibleNavItems"
:key="item.path"
:to="item.path"
class="app-nav-link"
:class="{ 'app-nav-link--active': isActive(item.path) }"
>
<component :is="item.icon" class="app-nav-link__icon h-5 w-5 text-system-label-tertiary" />
<span>{{ item.title }}</span>
</router-link>
</nav>
<div class="panel-muted mt-6">
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-system-label-tertiary">
使用建议
</p>
<div class="mt-3 space-y-3 text-sm leading-6 text-system-label-secondary">
<p>首页看整体健康度,先判断库存、借出和价值结构是否异常。</p>
<p>资产页适合做批量维护,使用页适合处理扫码出入库和借还追踪。</p>
<p>统计页可以快速筛出高价值类别和重点资产,方便汇报与盘点。</p>
<div class="mt-4 grid gap-2" :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"
@click="router.push('/settings')"
>
系统设置
</button>
<button
type="button"
class="btn-secondary min-h-[38px] px-3 text-xs"
@click="handleLogout"
>
退出登录
</button>
</div>
</div>
</aside>
<main class="app-main">
<router-view />
<!-- 主内容区 -->
<main class="flex min-w-0 flex-1 flex-col lg:ml-64">
<!-- 顶部栏 -->
<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">
<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>
</div>
</div>
<div class="hidden items-center gap-3 lg:flex">
<button
v-if="authStore.user?.role === 'admin'"
type="button"
class="btn-icon relative"
title="站内消息"
@click="router.push('/notifications')"
>
<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"
>
{{ 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>
</div>
</div>
</header>
<!-- 页面内容 -->
<div class="flex-1 overflow-auto px-4 py-5 sm:px-6">
<router-view />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import { useSiteStore } from '@/stores/site'
import { HomeIcon, CubeIcon, ArrowRightIcon, ChartBarIcon, Cog6ToothIcon } from '@heroicons/vue/24/outline'
import GlobalSearch from '@/components/GlobalSearch.vue'
import {
HomeIcon,
CubeIcon,
ArrowRightIcon,
ChartBarIcon,
BellIcon,
Cog6ToothIcon,
Bars3Icon,
XMarkIcon
} from '@heroicons/vue/24/outline'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
const siteStore = useSiteStore()
const mobileNavOpen = ref(false)
const navItems = [
{ path: '/dashboard', title: '首页', icon: HomeIcon },
{ path: '/equipment', title: '资产管理', icon: CubeIcon, 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 }
]
@ -128,13 +171,47 @@ const currentSection = computed(() => {
return (route.meta.title as string) || matchedNav?.title || '工作台'
})
const currentSubtitle = computed(() => {
if (route.path === '/dashboard') return '资产运行总览'
if (route.path === '/equipment') return '资产维护与盘点'
if (route.path.startsWith('/equipment/')) return '移动录入与现场维护'
if (route.path.startsWith('/borrow')) return '借还管理'
if (route.path.startsWith('/return/')) return '扫码归还'
if (route.path === '/statistics') return '数据分析'
if (route.path === '/notifications') return '同步提醒与待办'
if (route.path === '/settings') return '系统配置'
return ''
})
const isActive = (path: string) => route.path === path || route.path.startsWith(`${path}/`)
const userInitials = computed(() => {
const name = authStore.user?.name || authStore.user?.username || 'U'
return name.charAt(0).toUpperCase()
})
const handleLogout = async () => {
await authStore.logout()
router.push('/login')
}
watch(
() => route.fullPath,
() => {
mobileNavOpen.value = false
}
)
watch(
() => authStore.user?.role,
(role) => {
if (role === 'admin') {
notificationStore.fetchUnreadCount()
}
},
{ immediate: true }
)
onMounted(() => {
siteStore.fetchSiteConfig()
})

View File

@ -64,6 +64,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/statistics/Statistics.vue'),
meta: { title: '统计分析' }
},
{
path: 'notifications',
name: 'Notifications',
component: () => import('../views/notifications/NotificationCenter.vue'),
meta: { title: '站内消息', requiresAdmin: true }
},
{
path: 'settings',
name: 'Settings',

View File

@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { getUnreadNotificationCount } from '@/api/notifications'
interface NotificationState {
unreadCount: number
loaded: boolean
}
export const useNotificationStore = defineStore('notifications', {
state: (): NotificationState => ({
unreadCount: 0,
loaded: false
}),
actions: {
setUnreadCount(count: number) {
this.unreadCount = Math.max(0, Number(count) || 0)
this.loaded = true
},
async fetchUnreadCount() {
try {
const response = await getUnreadNotificationCount()
this.setUnreadCount(response.data?.count || 0)
} catch (error) {
console.warn('[notifications] 获取未读数量失败', error)
}
}
}
})

View File

@ -3,78 +3,37 @@ import { getSiteConfig } from '@/api/settings'
import type { SiteConfigResponse } from '@/api/settings'
const DEFAULT_SITE_NAME = '固定资产管理系统'
const DEFAULT_THEME_COLOR = '#409EFF'
const DEFAULT_LOGO_URL = ''
const DEFAULT_THEME_COLOR = '#10A37F'
const DEFAULT_LOGO_URL = '/branding/logo.svg'
const DEFAULT_FAVICON_URL = '/branding/favicon.svg'
const normalizeHex = (color: string) => {
if (!color) return null
const hex = color.trim()
const shortHexMatch = /^#?[0-9A-Fa-f]{3}$/
const longHexMatch = /^#?[0-9A-Fa-f]{6}$/
if (!shortHexMatch.test(hex) && !longHexMatch.test(hex)) {
return null
const getIconMimeType = (iconUrl: string) => {
const normalized = iconUrl.split('?')[0].split('#')[0]
const ext = normalized.split('.').pop()?.toLowerCase()
switch (ext) {
case 'svg':
return 'image/svg+xml'
case 'png':
return 'image/png'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'webp':
return 'image/webp'
default:
return 'image/x-icon'
}
const value = hex.startsWith('#') ? hex.slice(1) : hex
const expanded =
value.length === 3
? value
.split('')
.map((char) => char + char)
.join('')
: value
return `#${expanded.toUpperCase()}`
}
const hexChannel = (hex: string, index: number) => parseInt(hex.slice(index, index + 2), 16)
const mixChannel = (channel: number, amount: number, target: number) =>
Math.round(channel + (target - channel) * amount)
const lightenHex = (hex: string, amount: number) => {
const normalized = normalizeHex(hex)
if (!normalized) return hex
const r = hexChannel(normalized, 1)
const g = hexChannel(normalized, 3)
const b = hexChannel(normalized, 5)
const nr = mixChannel(r, amount, 255)
const ng = mixChannel(g, amount, 255)
const nb = mixChannel(b, amount, 255)
return `#${nr.toString(16).padStart(2, '0')}${ng.toString(16).padStart(2, '0')}${nb
.toString(16)
.padStart(2, '0')}`.toUpperCase()
}
const darkenHex = (hex: string, amount: number) => {
const normalized = normalizeHex(hex)
if (!normalized) return hex
const r = hexChannel(normalized, 1)
const g = hexChannel(normalized, 3)
const b = hexChannel(normalized, 5)
const nr = mixChannel(r, amount, 0)
const ng = mixChannel(g, amount, 0)
const nb = mixChannel(b, amount, 0)
return `#${nr.toString(16).padStart(2, '0')}${ng.toString(16).padStart(2, '0')}${nb
.toString(16)
.padStart(2, '0')}`.toUpperCase()
}
const hexToRgba = (hex: string, alpha: number) => {
const normalized = normalizeHex(hex)
if (!normalized) return `rgba(0, 122, 255, ${alpha})`
const r = hexChannel(normalized, 1)
const g = hexChannel(normalized, 3)
const b = hexChannel(normalized, 5)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const applyThemeColor = (color: string) => {
if (typeof document === 'undefined') return
const normalized = normalizeHex(color) || DEFAULT_THEME_COLOR
const normalized = color || DEFAULT_THEME_COLOR
const root = document.documentElement
root.style.setProperty('--theme-primary', normalized)
root.style.setProperty('--theme-primary-hover', lightenHex(normalized, 0.1))
root.style.setProperty('--theme-primary-active', darkenHex(normalized, 0.15))
root.style.setProperty('--theme-primary-ring', hexToRgba(normalized, 0.35))
root.style.setProperty('--theme-primary-hover', DEFAULT_THEME_COLOR)
root.style.setProperty('--theme-primary-active', DEFAULT_THEME_COLOR)
root.style.setProperty('--theme-primary-ring', 'rgba(16, 163, 127, 0.35)')
}
interface SiteState {
@ -91,7 +50,7 @@ export const useSiteStore = defineStore('site', {
state: (): SiteState => ({
siteName: DEFAULT_SITE_NAME,
logoUrl: DEFAULT_LOGO_URL,
faviconUrl: '',
faviconUrl: DEFAULT_FAVICON_URL,
themeColor: DEFAULT_THEME_COLOR,
loaded: false,
updatedAt: null,
@ -101,17 +60,15 @@ export const useSiteStore = defineStore('site', {
setSiteConfig(config: SiteConfigResponse) {
this.siteName = config.site_name || DEFAULT_SITE_NAME
this.logoUrl = config.logo_url || DEFAULT_LOGO_URL
this.faviconUrl = config.favicon_url || ''
this.themeColor = config.theme_color || DEFAULT_THEME_COLOR
this.faviconUrl = config.favicon_url || DEFAULT_FAVICON_URL
this.themeColor = DEFAULT_THEME_COLOR
this.updatedAt = config.updated_at
this.updatedBy = config.updated_by
this.loaded = true
applyThemeColor(this.themeColor)
if (typeof document !== 'undefined') {
document.title = this.siteName
if (this.faviconUrl) {
this.updateFavicon(this.faviconUrl)
}
this.updateFavicon(this.faviconUrl)
}
},
updateFavicon(iconUrl: string) {
@ -119,8 +76,8 @@ export const useSiteStore = defineStore('site', {
const link =
(document.querySelector("link[rel*='icon']") as HTMLLinkElement | null) ||
document.createElement('link')
link.type = 'image/x-icon'
link.rel = 'shortcut icon'
link.type = getIconMimeType(iconUrl)
link.rel = 'icon'
link.href = iconUrl
if (!link.parentNode) {
document.getElementsByTagName('head')[0].appendChild(link)

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,10 @@ export interface Department {
parent_id?: number | null
parent_name?: string | null
level?: number // 0=根部门, 1=子部门
source_group_id?: number | null
source_parent_group_id?: number | null
source_system?: string | null
last_synced_at?: string | null
parent?: {
id: number
name: string
@ -55,6 +59,7 @@ export interface Department {
}
equipment_count?: number
children_count?: number
person_count?: number
}
// 部门树形结构节点
@ -65,9 +70,14 @@ export interface DepartmentTreeNode {
parent_id: number | null
equipment_count: number
children_count: number
person_count?: number
children: DepartmentTreeNode[]
created_at: string
updated_at: string
source_group_id?: number | null
source_parent_group_id?: number | null
source_system?: string | null
last_synced_at?: string | null
}
// 设备接口
@ -125,12 +135,24 @@ export interface Person {
nickname: string
real_name: string
id_number?: string
department?: string
department?: string | null
main_department_id?: number | null
main_department?: Department | null
departments?: Department[]
department_names?: string[]
position?: string
phone?: string
email?: string
status: 'active' | 'inactive'
remarks?: string
source_userid?: string | null
source_system?: string | null
source_main_department_id?: number | null
source_dept_ids?: string | null
manager_source_userid?: string | null
source_employee_status?: string | null
last_synced_at?: string | null
is_synced?: boolean
created_at: string
updated_at: string
}
@ -223,9 +245,138 @@ export interface ValueStatistics {
}>
top_equipment: Array<{
equipment_name: string
quantity: number
unit_price: number
total_value: number
borrowed_value: number
available_value: number
item_value: number
stock_total_value: number
stock_borrowed_value: number
stock_available_value: number
}>
}
export interface HrSyncPreview {
departments: {
total: number
raw_total: number
sample: Array<{
sourceGroupId: number
sourceParentGroupId: number | null
name: string
}>
}
persons: {
total: number
sample: Array<{
sourceUserId: string
nickname: string
realName: string
departmentName: string | null
position: string | null
sourceMainDepartmentId: number | null
sourceDeptIds: string | null
status: 'active' | 'inactive'
}>
}
}
export interface HrSyncStatus {
enabled: boolean
complete: boolean
missingFields: string[]
scheduleTimes: string[]
}
export interface HrSyncResult {
departments: {
total: number
created: number
updated: number
deactivated: number
}
persons: {
total: number
created: number
updated: number
deactivated: number
}
sync_job_id?: number
changes_count?: number
}
export interface HrSyncJobHistoryItem {
id: number
sync_type: string
trigger_mode: string
status: 'success' | 'failed' | string
operator?: string | null
summary?: string | null
created_at: string
changes_count: number
counts?: Record<string, number>
error_message?: string | null
}
export type HrSyncChangeProcessingStatus = 'pending' | 'processed' | 'ignored'
export interface HrSyncChangeItem {
id: number
entity_type: string
entity_key?: string | null
entity_name: string
change_type: string
title: string
detail?: string | null
before_snapshot?: Record<string, any> | null
after_snapshot?: Record<string, any> | null
processing_status?: HrSyncChangeProcessingStatus | null
processing_notes?: string | null
processed_by?: number | null
processed_by_name?: string | null
processed_at?: string | null
created_at: string
}
export interface HrSyncJobDetail extends HrSyncJobHistoryItem {
changes: HrSyncChangeItem[]
}
export interface UpdateHrSyncChangeProcessingPayload {
processing_status: HrSyncChangeProcessingStatus
processing_notes?: string | null
}
export interface HrSyncJobHistoryResponse {
list: HrSyncJobHistoryItem[]
total: number
page: number
pageSize: number
}
export interface NotificationPreviewChange {
title: string
detail: string
change_type: string
}
export interface NotificationMetadata {
status?: 'success' | 'failed'
changes_count?: number
counts?: Record<string, number>
preview_changes?: NotificationPreviewChange[]
}
export interface NotificationItem {
id: number
notification_id: number
is_read: boolean
read_at?: string | null
created_at: string
notification_created_at: string
title: string
content?: string | null
type: string
level: 'info' | 'warning' | 'error' | string
action_url?: string | null
related_sync_job_id?: number | null
metadata?: NotificationMetadata | null
}

View File

@ -1,76 +1,111 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-system-background-secondary px-4">
<div class="w-full max-w-md space-y-8 rounded-apple-xl bg-system-background-primary p-8 shadow-apple-xl">
<div class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-apple bg-apple-blue/10 text-apple-title text-apple-blue">
<img
v-if="siteStore.logoUrl"
:src="siteStore.logoUrl"
:alt="siteStore.siteName"
class="h-full w-full object-cover"
/>
<span v-else></span>
</div>
<h1 class="text-apple-title-2 text-system-label-primary">{{ siteStore.siteName }}</h1>
<p class="text-apple-footnote text-system-label-tertiary">
{{ isLocalAuth ? '请输入账号密码登录系统' : `使用 ${keycloakHostname} 完成身份认证` }}
</p>
<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>
</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>
<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>
<form v-if="isLocalAuth" class="space-y-5" @submit.prevent="handleLocalLogin">
<div class="space-y-2 text-left">
<label class="text-apple-footnote text-system-label-secondary">用户名</label>
<input
v-model.trim="username"
class="input-apple"
type="text"
autocomplete="username"
placeholder="请输入用户名"
/>
</div>
<div class="space-y-2 text-left">
<label class="text-apple-footnote text-system-label-secondary">密码</label>
<input
v-model="password"
class="input-apple"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
/>
</div>
<button
type="submit"
class="btn-apple-primary w-full py-3 text-base"
:aria-busy="loading"
:disabled="loading || !canSubmit"
>
{{ loading ? '正在登录…' : '登录' }}
</button>
<p v-if="errorText" class="text-center text-apple-footnote text-red-500">{{ errorText }}</p>
</form>
<div v-else class="space-y-4">
<button
type="button"
class="btn-apple-primary w-full py-3 text-base"
:aria-busy="loading"
:disabled="loading"
@click="handleLogin"
>
{{ loginButtonLabel }}
</button>
<p class="text-center text-apple-footnote text-system-label-tertiary">
认证由 {{ keycloakHostname }} 提供。
</p>
</div>
<p class="text-center text-apple-footnote text-system-label-tertiary">© {{ currentYear }} {{ siteStore.siteName }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import keycloak from '@/auth/keycloak'
@ -99,11 +134,10 @@ const keycloakHostname = computed(() => {
const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak'
const isLocalAuth = computed(() => authMode === 'local')
const siteName = computed(() => siteStore.siteName || '固定资产管理系统')
const currentYear = new Date().getFullYear()
const loginButtonLabel = computed(() => (loading.value ? '正在安全跳转…' : '使用企业账号登录'))
const handleLogin = async () => {
loading.value = true
try {
@ -141,7 +175,6 @@ const handleLocalLogin = async () => {
roles: rawUser.roles || []
}
authStore.setUser(normalizedUser)
window.alert('登录成功')
await router.push('/dashboard')
} catch (e: any) {
errorText.value = e?.message || '登录失败,请重试'
@ -150,16 +183,6 @@ const handleLocalLogin = async () => {
}
}
watch(
() => authStore.isAuthenticated,
(isAuthenticated) => {
if (isAuthenticated) {
router.push('/dashboard')
}
},
{ immediate: true }
)
onMounted(() => {
siteStore.fetchSiteConfig()
})

View File

@ -1,21 +1,18 @@
<template>
<div class="space-y-6">
<!-- page-header -->
<section class="page-header">
<div>
<p class="page-kicker">Usage Flow</p>
<h2 class="page-title">借用与归还管理</h2>
<p class="page-subtitle">
把最近的借用动态、状态筛选和扫码入口放在一个页面里,方便仓库和管理员快速处理日常流转。
</p>
<div class="mt-5 flex flex-wrap gap-3">
<router-link to="/borrow/scan" class="btn-apple-primary">扫码出库</router-link>
<router-link to="/return/scan" class="btn-apple-secondary">扫码入库</router-link>
<button class="btn-apple-secondary" :disabled="exporting" @click="handleExport">
<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 ? '导出中...' : '导出借用记录' }}
</button>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">匹配记录</p>
@ -35,159 +32,188 @@
</div>
</section>
<section class="toolbar-panel">
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">状态</label>
<select v-model="filters.status" class="input-apple" @change="handleSearch">
<option value="">全部状态</option>
<option value="已借出">已借出</option>
<option value="已归还">已归还</option>
<option value="逾期">逾期</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-2 block text-sm font-medium text-system-label-primary">使用者 / 证件号</label>
<input
v-model.trim="filters.person_name"
class="input-apple"
type="text"
placeholder="输入花名、姓名或证件号"
@keyup.enter="handleSearch"
/>
</div>
</div>
<div class="flex flex-wrap gap-3">
<button class="btn-apple-secondary" @click="handleReset">重置</button>
<button class="btn-apple-primary" :disabled="loading" @click="handleSearch">
{{ loading ? '查询中...' : '查询' }}
</button>
<!-- 筛选区域 -->
<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>
</section>
<section class="grid grid-cols-1 gap-6 xl:grid-cols-[1.25fr,0.75fr]">
<div class="card-apple">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="section-title">借用记录清单</h3>
<p class="section-subtitle">资产、使用者、位置和归还计划同屏显示,减少反复点开查看详情。</p>
</div>
<span class="chip-muted">每页 {{ pageSize }} 条</span>
</div>
<!-- 数据列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">借用记录清单</h3>
<span class="badge badge-neutral">每页 {{ pageSize }} 条</span>
</div>
<div class="table-shell mt-5">
<div class="overflow-x-auto">
<table class="data-table min-w-[980px]">
<thead>
<tr>
<th class="text-left">资产</th>
<th class="text-left">使用者</th>
<th class="text-left">位置</th>
<th class="text-left">借用时间</th>
<th class="text-left">归还计划</th>
<th class="text-left">实际归还</th>
<th class="text-left">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in borrowRecords" :key="record.id">
<td>
<p class="font-medium text-system-label-primary">{{ record.equipment_name || '-' }}</p>
<p class="mt-1 font-mono text-xs text-system-label-tertiary">{{ record.equipment_code }}</p>
</td>
<td>
<p class="font-medium text-system-label-primary">{{ record.person_nickname }}</p>
<p class="mt-1 text-xs text-system-label-secondary">
{{ record.person_name }}
<span v-if="record.person?.department"> · {{ record.person.department }}</span>
</p>
</td>
<td>
<p class="text-sm text-system-label-primary">{{ record.storageLocation?.name || '-' }}</p>
<p v-if="record.storageLocation?.code" class="mt-1 text-xs text-system-label-tertiary">
{{ record.storageLocation.code }}
</p>
</td>
<td class="text-sm text-system-label-secondary">{{ formatDateTime(record.borrow_time) }}</td>
<td class="text-sm text-system-label-secondary">
<span v-if="record.is_long_term" class="chip-muted">在职长期</span>
<template v-else>
{{ record.expected_return_time ? formatDateTime(record.expected_return_time) : '-' }}
</template>
</td>
<td class="text-sm text-system-label-secondary">
{{ record.return_time ? formatDateTime(record.return_time) : '-' }}
</td>
<td>
<span class="inline-flex rounded-full px-3 py-1 text-xs font-medium" :class="getStatusClass(record.status)">
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="!loading && borrowRecords.length === 0">
<td colspan="7" class="text-center text-sm text-system-label-secondary">当前筛选条件下暂无记录。</td>
</tr>
</tbody>
</table>
<!-- 移动端卡片 -->
<div class="mobile-card-list md:hidden">
<article
v-for="record in borrowRecords"
:key="`mobile-${record.id}`"
class="mobile-card"
>
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">{{ record.equipment_name || '-' }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ record.equipment_code }}</p>
</div>
<span class="badge" :class="getStatusBadgeClass(record.status)">
{{ record.status }}
</span>
</div>
</div>
<div class="mt-4 flex items-center justify-between border-t border-[color:var(--border-soft)] pt-4">
<p class="text-sm text-system-label-secondary">共 {{ formatNumber(total) }} 条记录</p>
<div class="flex items-center gap-2">
<button @click="handlePrevPage" :disabled="currentPage === 1" class="btn-apple-secondary">上一页</button>
<span class="text-sm font-medium text-system-label-primary">{{ currentPage }} / {{ totalPages }}</span>
<button @click="handleNextPage" :disabled="currentPage >= totalPages" class="btn-apple-secondary">下一页</button>
<div class="mobile-card__meta">
<div>
<p class="mobile-card__label">使用者</p>
<p class="mobile-card__value">
{{ record.person_nickname }}
<span class="block text-xs text-text-secondary">{{ record.person_name }}</span>
</p>
</div>
<div>
<p class="mobile-card__label">位置</p>
<p class="mobile-card__value">{{ record.storageLocation?.name || '-' }}</p>
</div>
<div>
<p class="mobile-card__label">借用时间</p>
<p class="mobile-card__value">{{ formatDateTime(record.borrow_time) }}</p>
</div>
<div>
<p class="mobile-card__label">归还计划</p>
<p class="mobile-card__value">
{{ record.is_long_term ? '在职长期' : (record.expected_return_time ? formatDateTime(record.expected_return_time) : '-') }}
</p>
</div>
</div>
</article>
<p v-if="!loading && borrowRecords.length === 0" class="text-center text-sm text-text-tertiary py-8">
当前筛选条件下暂无记录。
</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>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in borrowRecords" :key="record.id">
<td>
<p class="font-medium text-text-primary">{{ record.equipment_name || '-' }}</p>
<p class="text-xs text-text-tertiary font-mono">{{ record.equipment_code }}</p>
</td>
<td>
<p class="text-text-primary">{{ record.person_nickname }}</p>
<p class="text-xs text-text-secondary">
{{ record.person_name }}
<span v-if="record.person?.department"> · {{ record.person.department }}</span>
</p>
</td>
<td>
<p class="text-sm text-text-primary">{{ record.storageLocation?.name || '-' }}</p>
</td>
<td class="text-sm text-text-secondary">{{ formatDateTime(record.borrow_time) }}</td>
<td class="text-sm text-text-secondary">
<span v-if="record.is_long_term" class="badge badge-neutral">在职长期</span>
<template v-else>
{{ record.expected_return_time ? formatDateTime(record.expected_return_time) : '-' }}
</template>
</td>
<td class="text-sm text-text-secondary">
{{ record.return_time ? formatDateTime(record.return_time) : '-' }}
</td>
<td>
<span class="badge" :class="getStatusBadgeClass(record.status)">
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="!loading && borrowRecords.length === 0">
<td colspan="7" class="text-center text-text-secondary py-8">当前筛选条件下暂无记录。</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="space-y-6">
<section class="card-apple">
<h3 class="section-title">当前页状态快照</h3>
<p class="section-subtitle">不用切图表也能快速判断这一页记录的流转结构。</p>
<div class="mt-5 space-y-3">
<div class="panel-muted flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">已借出</p>
<p class="mt-2 text-lg font-semibold text-apple-orange">{{ formatNumber(pageSummary.borrowed) }}</p>
</div>
<span class="chip-muted">待归还</span>
</div>
<div class="panel-muted flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">已归还</p>
<p class="mt-2 text-lg font-semibold text-apple-green">{{ formatNumber(pageSummary.returned) }}</p>
</div>
<span class="chip-muted">已完成</span>
</div>
<div class="panel-muted flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">长期借用</p>
<p class="mt-2 text-lg font-semibold text-system-label-primary">{{ formatNumber(pageSummary.longTerm) }}</p>
</div>
<span class="chip-muted">在职长期</span>
</div>
</div>
</section>
<section class="card-apple">
<h3 class="section-title">处理提示</h3>
<p class="section-subtitle">这块保留给仓库和管理员做日常巡检时快速参考。</p>
<div class="mt-5 space-y-3 text-sm leading-6 text-system-label-secondary">
<div class="panel-muted">状态筛选建议先看“已借出”,能最快定位需要催还和追踪的记录。</div>
<div class="panel-muted">长期借用数量偏高时,建议回到资产页核查是否存在长期占用未回收的设备。</div>
<div class="panel-muted">需要现场处理时,优先使用上方扫码出库/扫码入库入口,避免手工录入。</div>
</div>
</section>
<!-- 分页 -->
<div class="pagination-bar">
<p class="text-sm text-text-secondary">共 {{ formatNumber(total) }} 条记录</p>
<div class="flex items-center gap-2">
<button @click="handlePrevPage" :disabled="currentPage === 1" class="btn-secondary text-sm">上一页</button>
<span class="text-sm text-text-primary">{{ currentPage }} / {{ totalPages }}</span>
<button @click="handleNextPage" :disabled="currentPage >= totalPages" class="btn-secondary text-sm">下一页</button>
</div>
</div>
</section>
<!-- 状态快照 -->
<div class="space-y-6">
<section class="card">
<h3 class="section-title">状态快照</h3>
<div class="mt-4 space-y-3">
<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>
</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>
</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>
</div>
<span class="badge badge-neutral">在职长期</span>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { getBorrowRecordList } from '@/api/borrow'
import { downloadFile, exportBorrowRecords } from '@/api/export'
import type { BorrowRecord } from '@/types'
import { formatDateTime, formatNumber } from '@/utils/format'
@ -200,39 +226,32 @@ const pageSize = ref(20)
const filters = reactive({
status: '',
person_name: ''
person_name: '',
keyword: ''
})
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pageSummary = computed(() => {
const summary = {
borrowed: 0,
returned: 0,
longTerm: 0
}
for (const record of borrowRecords.value) {
if (record.status === '已借出') summary.borrowed += 1
if (record.status === '已归还') summary.returned += 1
if (record.is_long_term) summary.longTerm += 1
}
return summary
const borrowed = borrowRecords.value.filter(r => r.status === '已借出').length
const returned = borrowRecords.value.filter(r => r.status === '已归还').length
const longTerm = borrowRecords.value.filter(r => r.is_long_term).length
return { borrowed, returned, longTerm }
})
const fetchBorrowRecords = async () => {
const fetchData = async () => {
loading.value = true
try {
const response = await getBorrowRecordList({
const res = await getBorrowRecordList({
page: currentPage.value,
pageSize: pageSize.value,
status: filters.status || undefined,
person_name: filters.person_name || undefined
person_name: filters.person_name || undefined,
keyword: filters.keyword || undefined
})
if (response.data) {
borrowRecords.value = response.data.list
total.value = response.data.total
if (res.data) {
borrowRecords.value = res.data.list
total.value = res.data.total
}
} catch (error) {
console.error('获取借用记录失败:', error)
@ -241,74 +260,57 @@ const fetchBorrowRecords = async () => {
}
}
const handleExport = async () => {
exporting.value = true
try {
const params = new URLSearchParams()
if (filters.status) params.append('status', filters.status)
if (filters.person_name) params.append('person_name', filters.person_name)
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('token')
const response = await fetch(`${baseUrl}/export/borrow-records?${params.toString()}`, {
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('导出失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `借用记录_${new Date().toISOString().slice(0, 10)}.xlsx`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error: any) {
alert(error.message || '导出失败')
} finally {
exporting.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchBorrowRecords()
fetchData()
}
const handleReset = () => {
filters.status = ''
filters.person_name = ''
filters.keyword = ''
handleSearch()
}
const handleExport = async () => {
exporting.value = true
try {
const response = await exportBorrowRecords({
status: filters.status || undefined,
person_name: filters.person_name || undefined
})
downloadFile(response.data as Blob, '借用记录.xlsx')
} catch (error) {
console.error('导出借用记录失败:', error)
} finally {
exporting.value = false
}
}
const handlePrevPage = () => {
if (currentPage.value <= 1) return
currentPage.value -= 1
fetchBorrowRecords()
if (currentPage.value > 1) {
currentPage.value--
fetchData()
}
}
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) return
currentPage.value += 1
fetchBorrowRecords()
if (currentPage.value < totalPages.value) {
currentPage.value++
fetchData()
}
}
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'已借出': 'bg-apple-orange/15 text-apple-orange',
'已归还': 'bg-apple-green/15 text-apple-green',
'逾期': 'bg-apple-red/15 text-apple-red'
const getStatusBadgeClass = (status: string) => {
const map: Record<string, string> = {
'已借出': 'badge-warning',
'已归还': 'badge-success',
'逾期': 'badge-error'
}
return classes[status] || 'bg-system-gray-5 text-system-label-secondary'
return map[status] || 'badge-neutral'
}
onMounted(() => {
fetchBorrowRecords()
fetchData()
})
</script>

View File

@ -1,118 +1,179 @@
<template>
<div class="space-y-6">
<!-- Scan Input Area -->
<div class="card-apple">
<div class="mb-6">
<label class="block text-apple-body text-system-label-primary mb-2">资产编码</label>
<input
ref="scanInputRef"
v-model="scanCode"
type="text"
class="input-apple text-lg"
placeholder="请使用扫码枪扫描资产条码"
@keyup.enter="handleScan"
@focus="handleFocus"
/>
<p class="text-apple-footnote text-system-label-tertiary mt-2">
提示:扫描条码后按回车键或等待自动识别
</p>
<!-- page-header -->
<section class="page-header">
<div>
<p class="page-kicker">Quick Checkout</p>
<h2 class="page-title">扫码借出</h2>
</div>
</section>
<!-- 扫码区域 -->
<div class="scanner-hero">
<div class="flex flex-col gap-4 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>
</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 ? '收起手动输入' : '手动输入 / 扫码枪' }}
</button>
</div>
</div>
<!-- Equipment Info -->
<div v-if="equipmentInfo" class="mt-6 p-4 bg-system-gray-6 rounded-apple space-y-4">
<h3 class="text-apple-title mb-4">资产信息</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-apple-footnote text-system-label-secondary">资产编码</p>
<p class="text-apple-body font-mono">{{ equipmentInfo.equipment_code }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">资产名称</p>
<p class="text-apple-body">{{ equipmentInfo.equipment_name }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">资产型号</p>
<p class="text-apple-body">{{ equipmentInfo.equipment_model || '-' }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">资产状态</p>
<p class="text-apple-body">{{ equipmentInfo.status }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">地址</p>
<p class="text-apple-body">{{ equipmentInfo.storageLocation?.name || '-' }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">单价</p>
<p class="text-apple-body">{{ formatCurrency(equipmentInfo.unit_price) }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">可用数量</p>
<p class="text-apple-body">{{ equipmentInfo.remaining_quantity }}</p>
<div class="scanner-hero__stats">
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">推荐方式</p>
<p class="scanner-hero__stat-value">手机摄像头</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">识别结果</p>
<p class="scanner-hero__stat-value">{{ equipmentInfo ? '已识别资产' : '等待扫码' }}</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">当前可借</p>
<p class="scanner-hero__stat-value">{{ equipmentInfo ? equipmentInfo.remaining_quantity : '-' }}</p>
</div>
</div>
<div v-if="showManualEntry" class="form-panel">
<div class="form-panel__header">
<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">
<input
ref="scanInputRef"
v-model="scanCode"
type="text"
class="input text-lg"
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>
</div>
</div>
</div>
<!-- 资产信息卡片 -->
<section v-if="equipmentInfo" class="card mt-6">
<div class="list-header">
<div class="flex items-center gap-3">
<h3 class="section-title">资产信息</h3>
<span class="badge" :class="getStatusBadgeClass(equipmentInfo.status)">
{{ equipmentInfo.status }}
</span>
</div>
<button type="button" class="btn-secondary text-sm" @click="showCameraScanner = true">
重新扫码
</button>
</div>
<div class="field-grid">
<div class="field-stack">
<p class="field-label">资产编码</p>
<p class="text-sm text-text-primary font-mono">{{ equipmentInfo.equipment_code }}</p>
</div>
<div class="field-stack">
<p class="field-label">资产名称</p>
<p class="text-sm text-text-primary">{{ equipmentInfo.equipment_name }}</p>
</div>
<div class="field-stack">
<p class="field-label">资产型号</p>
<p class="text-sm text-text-secondary">{{ equipmentInfo.equipment_model || '-' }}</p>
</div>
<div class="field-stack">
<p class="field-label">地址</p>
<p class="text-sm text-text-secondary">{{ equipmentInfo.storageLocation?.name || '-' }}</p>
</div>
<div class="field-stack">
<p class="field-label">单价</p>
<p class="text-sm text-text-secondary">{{ formatCurrency(equipmentInfo.unit_price) }}</p>
</div>
<div class="field-stack">
<p class="field-label">可用数量</p>
<p class="text-sm font-medium" :class="equipmentInfo.remaining_quantity > 0 ? 'text-status-success' : 'text-status-error'">
{{ equipmentInfo.remaining_quantity }}
</p>
</div>
</div>
<!-- 已挂载配件列表 -->
<div v-if="mountedChildren.length > 0" class="mt-4 pt-4 border-t border-system-gray-5">
<h4 class="text-apple-headline mb-3">已挂载配件(将随主资产一起借出)</h4>
<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-for="child in mountedChildren"
:key="child.id"
class="flex items-center justify-between p-2 bg-system-background-primary rounded-apple"
class="panel flex items-center justify-between"
>
<div>
<span class="font-medium">{{ child.equipment_name }}</span>
<span class="text-apple-footnote text-system-label-secondary ml-2">
<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>
</div>
</div>
<p class="text-apple-footnote text-system-label-tertiary mt-2">
<p class="text-sm text-text-tertiary mt-2">
配件总价值:¥{{ mountedChildrenValue.toFixed(2) }}
</p>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p class="text-apple-footnote text-system-label-secondary">
<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">
请核对资产信息无误后再进行出库操作。
</p>
<div class="flex items-center gap-3">
<button
v-if="!equipmentConfirmed"
class="btn-apple-secondary"
class="btn-primary"
@click="equipmentConfirmed = true"
>
确认资产信息
</button>
<span
v-else
class="inline-flex items-center rounded-full bg-apple-green/15 px-3 py-1 text-apple-footnote text-apple-green"
class="badge badge-success"
>
✅ 已确认
已确认
</span>
</div>
</div>
</div>
</section>
</div>
<!-- Borrower Info Form -->
<div class="card-apple">
<h2 class="text-apple-title mb-4">使用者信息</h2>
<CameraScannerModal
v-model="showCameraScanner"
title="扫码借出"
description="将资产二维码对准镜头,识别成功后会自动读取资产信息。"
manual-placeholder="请输入资产编码"
@detected="handleCameraDetected"
/>
<!-- 借用表单 -->
<section v-if="equipmentInfo" class="card">
<h3 class="section-title mb-4">使用者信息</h3>
<div class="space-y-4">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">使用者 *</label>
<div class="space-y-2">
<input
<div class="field-stack">
<label class="field-label">使用者 *</label>
<div class="space-y-3">
<input
v-model.trim="personKeyword"
type="text"
class="input-apple"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 证件号进行搜索"
/>
<select
v-model.number="selectedPersonId"
class="input-apple"
class="select hidden md:block"
>
<option value="" disabled>请选择使用者</option>
<option
@ -123,11 +184,35 @@
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }}
</option>
</select>
<p class="text-apple-footnote text-system-label-tertiary">
<div class="touch-option-list md:hidden">
<button
v-for="person in topFilteredPersons"
:key="person.id"
type="button"
class="touch-option"
:class="{ 'is-active': selectedPersonId === person.id }"
@click="selectedPersonId = person.id"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
<p class="mt-1 text-xs text-text-secondary">
{{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
</p>
</div>
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
</div>
</button>
<p v-if="topFilteredPersons.length === 0" class="text-center text-sm text-text-tertiary py-4">
当前没有匹配到使用者,请换个关键词再试。
</p>
</div>
<p class="text-sm text-text-tertiary">
未找到需要的人员?前往
<router-link
:to="{ path: '/settings', query: { tab: 'persons' } }"
class="text-apple-blue underline"
class="font-medium text-accent hover:underline"
>
系统设置 &gt; 使用者信息
</router-link>
@ -136,21 +221,35 @@
</div>
</div>
<div v-if="selectedPerson">
<div class="rounded-apple bg-system-gray-6 px-4 py-3 text-apple-footnote text-system-label-secondary">
<p>花名:{{ selectedPerson.nickname }}</p>
<p>姓名:{{ selectedPerson.real_name }}</p>
<p v-if="selectedPerson.department">部门:{{ selectedPerson.department }}</p>
<p v-if="selectedPerson.phone">电话:{{ selectedPerson.phone }}</p>
<div class="panel">
<div class="field-grid text-sm">
<div>
<span class="text-text-tertiary">花名:</span>
<span class="text-text-primary">{{ selectedPerson.nickname }}</span>
</div>
<div>
<span class="text-text-tertiary">姓名:</span>
<span class="text-text-primary">{{ selectedPerson.real_name }}</span>
</div>
<div v-if="selectedPerson.department">
<span class="text-text-tertiary">部门:</span>
<span class="text-text-primary">{{ selectedPerson.department }}</span>
</div>
<div v-if="selectedPerson.phone">
<span class="text-text-tertiary">电话:</span>
<span class="text-text-primary">{{ selectedPerson.phone }}</span>
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-apple-body text-system-label-primary">预计归还时间</label>
<label class="flex items-center space-x-2 text-apple-footnote text-system-label-secondary">
<div class="field-stack">
<div class="flex items-center justify-between">
<label class="field-label">预计归还时间</label>
<label class="flex items-center space-x-2 text-sm text-text-secondary cursor-pointer">
<input
v-model="borrowForm.is_long_term"
type="checkbox"
class="h-4 w-4 rounded border-system-gray-4 text-apple-blue focus:ring-apple-blue"
class="h-4 w-4 rounded border-border bg-bg-secondary text-accent-primary focus:ring-accent-primary/20"
/>
<span>在职长期</span>
</label>
@ -158,39 +257,51 @@
<input
v-model="borrowForm.expected_return_time"
type="datetime-local"
class="input-apple"
class="input"
:disabled="borrowForm.is_long_term"
/>
<p class="mt-1 text-apple-footnote text-system-label-tertiary">
选择“在职长期”后将不设置具体归还日期。
<p class="text-sm text-text-tertiary">
选择"在职长期"后将不设置具体归还日期。
</p>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<div class="field-stack">
<label class="field-label">备注</label>
<textarea
v-model="borrowForm.notes"
class="input-apple"
class="input"
rows="3"
placeholder="请输入备注信息"
></textarea>
</div>
<button
@click="handleSubmit"
:disabled="!canSubmit"
class="btn-apple-primary w-full"
>
{{ loading ? '提交中...' : '确认出库' }}
</button>
<div class="mobile-sticky-actions md:static">
<button
@click="handleSubmit"
:disabled="!canSubmit"
class="btn-primary w-full"
>
{{ loading ? '提交中...' : '确认出库' }}
</button>
</div>
</div>
</div>
</section>
<div v-if="successInfo" class="card-apple border border-apple-green/40 bg-apple-green/5">
<h2 class="text-apple-title text-apple-green mb-2">出库成功</h2>
<p class="text-apple-body text-system-label-secondary">资产 {{ successInfo.equipment_name }} ({{ successInfo.equipment_code }})已成功出库。</p>
<p class="text-apple-footnote text-system-label-tertiary mt-2">
出库人:{{ successInfo.person_display }};时间:{{ successInfo.borrow_time }}
</p>
</div>
<!-- 成功消息 -->
<section v-if="successInfo" class="card border border-status-success/40 bg-status-success/5">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-status-success/20 flex items-center justify-center">
<svg class="w-5 h-5 text-status-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<div>
<h3 class="text-base font-medium text-status-success mb-1">出库成功</h3>
<p class="text-sm text-text-secondary">资产 {{ successInfo.equipment_name }} ({{ successInfo.equipment_code }})已成功出库。</p>
<p class="text-sm text-text-tertiary mt-2">
出库人:{{ successInfo.person_display }};时间:{{ successInfo.borrow_time }}
</p>
</div>
</div>
</section>
</div>
</template>
@ -204,10 +315,13 @@ import { getChildren } from '@/api/mount'
import type { Equipment, Person } from '@/types'
import type { ChildEquipment } from '@/api/mount'
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
const scanCode = ref('')
const scanInputRef = ref<HTMLInputElement>()
const route = useRoute()
const showCameraScanner = ref(false)
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
const equipmentInfo = ref<Equipment | null>(null)
const equipmentConfirmed = ref(false)
const mountedChildren = ref<ChildEquipment[]>([])
@ -249,20 +363,43 @@ const filteredPersons = computed(() => {
person.nickname.toLowerCase().includes(keyword) ||
person.real_name.toLowerCase().includes(keyword) ||
(person.id_number && person.id_number.toLowerCase().includes(keyword)) ||
(person.department && person.department.toLowerCase().includes(keyword))
(person.department && person.department.toLowerCase().includes(keyword)) ||
Boolean(person.department_names?.some((name) => name.toLowerCase().includes(keyword)))
)
})
})
const topFilteredPersons = computed(() => filteredPersons.value.slice(0, 8))
const selectedPerson = computed(() => {
if (!selectedPersonId.value) return null
return persons.value.find((person) => person.id === selectedPersonId.value) || null
})
const getStatusBadgeClass = (status: string) => {
switch (status) {
case '可用':
return 'badge-success'
case '已借出':
return 'badge-warning'
case '维护中':
return 'badge-error'
case '已报废':
return 'badge-neutral'
default:
return 'badge-neutral'
}
}
const handleFocus = () => {
scanCode.value = ''
}
const handleCameraDetected = async (value: string) => {
scanCode.value = value.trim()
await handleScan()
}
const handleScan = async () => {
if (!scanCode.value) {
return
@ -284,7 +421,7 @@ const handleScan = async () => {
is_long_term: false
}
playBeepSound()
// 获取已挂载的子资产
try {
const childrenResponse = await getChildren(response.data.id)
@ -295,7 +432,7 @@ const handleScan = async () => {
} catch (e) {
// 忽略获取子资产失败的错误
}
// 清空扫码框,准备下次扫码
scanCode.value = ''
nextTick(() => {

View File

@ -1,206 +1,321 @@
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Quick Return</p>
<h2 class="page-title">扫码归还</h2>
</div>
</section>
<!-- Scan Input Area -->
<div class="card-apple">
<div class="mb-6">
<label class="block text-apple-body text-system-label-primary mb-2">资产编码</label>
<input
ref="scanInputRef"
v-model="scanCode"
type="text"
class="input-apple text-lg"
placeholder="请使用扫码枪扫描资产条码"
@keyup.enter="handleScan"
@focus="handleFocus"
/>
<p class="text-apple-footnote text-system-label-tertiary mt-2">
提示:扫描条码后按回车键或等待自动识别
</p>
<div class="scanner-hero">
<div class="flex flex-col gap-4 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>
</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 ? '收起手动输入' : '手动输入 / 扫码枪' }}
</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">{{ borrowRecordInfo ? '待确认归还' : '等待扫码' }}</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">借用记录</p>
<p class="scanner-hero__stat-value">{{ borrowRecordInfo?.borrow_records.length || 0 }}</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">配件核对</p>
<p class="scanner-hero__stat-value">{{ hasChildrenDiff ? '有差异' : '正常' }}</p>
</div>
<div class="scanner-hero__stat">
<p class="scanner-hero__stat-label">连续归还</p>
<p class="scanner-hero__stat-value">{{ returnSuccessCount }}</p>
</div>
</div>
<div
v-if="pageNotice"
class="rounded-lg border px-4 py-3 text-sm"
:class="pageNotice.type === 'success'
? 'border-status-success/30 bg-status-success/10 text-status-success'
: pageNotice.type === 'warning'
? 'border-status-warning/30 bg-status-warning/10 text-status-warning'
: 'border-status-error/30 bg-status-error/10 text-status-error'"
>
{{ pageNotice.message }}
</div>
<div v-if="showManualEntry" class="form-panel">
<div class="form-panel__header">
<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">
<input
ref="scanInputRef"
v-model="scanCode"
type="text"
class="input text-lg"
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>
</div>
</div>
</div>
<!-- Equipment and Borrow Record Info -->
<div v-if="borrowRecordInfo" class="mt-6 p-4 bg-system-gray-6 rounded-apple">
<h3 class="text-apple-title mb-4">资产信息</h3>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<p class="text-apple-footnote text-system-label-secondary">资产编码</p>
<p class="text-apple-body font-mono">{{ borrowRecordInfo.equipment_code }}</p>
<section v-if="borrowRecordInfo" class="card mt-6">
<div class="list-header">
<div class="flex items-center gap-3">
<h3 class="section-title">资产信息</h3>
<span class="badge badge-warning">已借出</span>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">资产名称</p>
<p class="text-apple-body">{{ borrowRecordInfo.equipment_name }}</p>
<button type="button" class="btn-secondary text-sm" @click="showCameraScanner = true">
重新扫码
</button>
</div>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">资产编码</label>
<p class="text-sm text-text-primary font-mono">{{ borrowRecordInfo.equipment_code }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">地址</p>
<p class="text-apple-body">
<div class="field-stack">
<label class="field-label">资产名称</label>
<p class="text-sm text-text-primary">{{ borrowRecordInfo.equipment_name }}</p>
</div>
<div class="field-stack">
<label class="field-label">地址</label>
<p class="text-sm text-text-secondary">
{{ borrowRecordInfo.storageLocation?.name || '-' }}
<span v-if="borrowRecordInfo.storageLocation?.code">
<span v-if="borrowRecordInfo.storageLocation?.code" class="text-text-tertiary">
({{ borrowRecordInfo.storageLocation?.code }})
</span>
</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">单价</p>
<p class="text-apple-body">{{ formatCurrency(borrowRecordInfo.unit_price) }}</p>
<div class="field-stack">
<label class="field-label">单价</label>
<p class="text-sm text-text-secondary">{{ formatCurrency(borrowRecordInfo.unit_price) }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">资产价值</p>
<p class="text-apple-body">{{ formatCurrency(borrowRecordInfo.equipment_value) }}</p>
<div class="field-stack">
<label class="field-label">资产价值</label>
<p class="text-sm text-text-secondary">{{ formatCurrency(borrowRecordInfo.equipment_value) }}</p>
</div>
</div>
<h3 class="text-apple-title mb-4">借用记录</h3>
<div class="space-y-4">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">选择归还使用者</label>
<select v-model="selectedBorrowRecordId" class="input-apple" @change="handleRecordSelect">
<option value="" disabled>请选择归还使用者</option>
<option
v-for="record in borrowRecordInfo.borrow_records"
:key="record.id"
:value="String(record.id)"
<div class="border-t border-border pt-6 mt-6">
<h3 class="section-title mb-4">借用记录</h3>
<div class="space-y-4">
<div class="field-stack">
<label class="field-label">选择归还使用者</label>
<select v-model="selectedBorrowRecordId" class="select hidden md:block" @change="handleRecordSelect">
<option value="" disabled>请选择归还使用者</option>
<option
v-for="record in borrowRecordInfo.borrow_records"
:key="record.id"
:value="String(record.id)"
>
{{ record.person_nickname }} · {{ record.person_name }}
(借用时间:{{ formatDateTime(record.borrow_time) }})
</option>
</select>
<div class="mobile-card-list md:hidden">
<article
v-for="record in borrowRecordInfo.borrow_records"
:key="record.id"
class="mobile-card"
:class="{ 'border-accent-primary/30': selectedBorrowRecordId === String(record.id) }"
@click="selectBorrowRecord(record.id)"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ record.person_nickname }} · {{ record.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ record.is_long_term ? '在职长期' : (record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置归还时间') }}
</p>
</div>
<span v-if="selectedBorrowRecordId === String(record.id)" class="badge badge-success">已选中</span>
</div>
</article>
</div>
<p
v-if="borrowRecordInfo.borrow_records.length > 1"
class="text-sm text-text-tertiary mt-2"
>
{{ record.person_nickname }} · {{ record.person_name }}
(借用时间:{{ formatDateTime(record.borrow_time) }})
</option>
</select>
<p
v-if="borrowRecordInfo.borrow_records.length > 1"
class="text-apple-footnote text-system-label-tertiary mt-2"
>
提示:该资产有多位使用者同时借出,请选择正确的归还人。
</p>
</div>
<div v-if="selectedBorrowRecord" class="grid grid-cols-2 gap-4">
<div>
<p class="text-apple-footnote text-system-label-secondary">使用者</p>
<p class="text-apple-body">
{{ selectedBorrowRecord.person_nickname }} · {{ selectedBorrowRecord.person_name }}
提示:该资产有多位使用者同时借出,请选择正确的归还人。
</p>
</div>
<div v-if="selectedBorrowRecord.person?.department">
<p class="text-apple-footnote text-system-label-secondary">部门</p>
<p class="text-apple-body">{{ selectedBorrowRecord.person?.department }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">借用时间</p>
<p class="text-apple-body">{{ formatDateTime(selectedBorrowRecord.borrow_time) }}</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">预计归还时间</p>
<p class="text-apple-body">
{{
selectedBorrowRecord.is_long_term
? '在职长期'
: selectedBorrowRecord.expected_return_time
? formatDateTime(selectedBorrowRecord.expected_return_time)
: '未设置'
}}
</p>
</div>
<div>
<p class="text-apple-footnote text-system-label-secondary">状态</p>
<p class="text-apple-body">{{ selectedBorrowRecord.status }}</p>
</div>
</div>
<!-- 配件快照对比 -->
<div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-4 pt-4 border-t border-system-gray-5">
<h4 class="text-apple-headline mb-3">配件对比</h4>
<!-- 借出时配件 -->
<div v-if="childrenSnapshot.length > 0" class="mb-4">
<p class="text-apple-footnote text-system-label-secondary mb-2">借出时配件:</p>
<div class="space-y-1">
<div
v-for="child in childrenSnapshot"
:key="child.id"
class="flex items-center justify-between p-2 rounded-apple"
:class="isChildMissing(child.id) ? 'bg-apple-red/10' : 'bg-system-background-primary'"
>
<span :class="isChildMissing(child.id) ? 'text-apple-red' : ''">
{{ child.equipment_name }} ({{ child.equipment_code }})
<div v-if="selectedBorrowRecord" class="panel field-grid">
<div class="field-stack">
<label class="field-label">使用者</label>
<p class="text-sm text-text-primary">
{{ selectedBorrowRecord.person_nickname }} · {{ selectedBorrowRecord.person_name }}
</p>
</div>
<div v-if="selectedBorrowRecord.person?.department" class="field-stack">
<label class="field-label">部门</label>
<p class="text-sm text-text-secondary">{{ selectedBorrowRecord.person?.department }}</p>
</div>
<div class="field-stack">
<label class="field-label">借用时间</label>
<p class="text-sm text-text-secondary">{{ formatDateTime(selectedBorrowRecord.borrow_time) }}</p>
</div>
<div class="field-stack">
<label class="field-label">预计归还时间</label>
<p class="text-sm text-text-secondary">
{{
selectedBorrowRecord.is_long_term
? '在职长期'
: selectedBorrowRecord.expected_return_time
? formatDateTime(selectedBorrowRecord.expected_return_time)
: '未设置'
}}
</p>
</div>
<div class="field-stack">
<label class="field-label">状态</label>
<p class="text-sm">
<span class="badge" :class="getStatusBadgeClass(selectedBorrowRecord.status)">
{{ selectedBorrowRecord.status }}
</span>
<span v-if="isChildMissing(child.id)" class="text-apple-red text-apple-footnote">
⚠️ 已缺失
</span>
</div>
</p>
</div>
</div>
<!-- 当前配件 -->
<div v-if="currentChildren.length > 0">
<p class="text-apple-footnote text-system-label-secondary mb-2">当前配件:</p>
<div class="space-y-1">
<div
v-for="child in currentChildren"
:key="child.id"
class="flex items-center justify-between p-2 rounded-apple"
:class="isChildNew(child.id) ? 'bg-apple-green/10' : 'bg-system-background-primary'"
>
<span :class="isChildNew(child.id) ? 'text-apple-green' : ''">
{{ child.equipment_name }} ({{ child.equipment_code }})
</span>
<span v-if="isChildNew(child.id)" class="text-apple-green text-apple-footnote">
✨ 新增
</span>
<!-- 配件快照对比 -->
<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" class="mb-4">
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p>
<div class="space-y-2">
<div
v-for="child in childrenSnapshot"
:key="child.id"
class="flex items-center justify-between p-3 rounded-md"
: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">
{{ child.equipment_name }} ({{ child.equipment_code }})
</span>
<span v-if="isChildMissing(child.id)" class="text-status-error text-xs">
<svg class="w-4 h-4 inline mr-1" 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>
已缺失
</span>
</div>
</div>
</div>
</div>
<!-- 差异提示 -->
<div v-if="hasChildrenDiff" class="mt-3 p-3 bg-apple-orange/10 rounded-apple">
<p class="text-apple-footnote text-apple-orange">
⚠️ 配件与借出时不一致,请确认配件变更情况
</p>
<!-- 当前配件 -->
<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
v-for="child in currentChildren"
:key="child.id"
class="flex items-center justify-between p-3 rounded-md"
: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">
{{ child.equipment_name }} ({{ child.equipment_code }})
</span>
<span v-if="isChildNew(child.id)" class="text-status-success text-xs">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
新增
</span>
</div>
</div>
</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">
<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>
</div>
</div>
</div>
</section>
</div>
<CameraScannerModal
v-model="showCameraScanner"
title="扫码归还"
description="将资产二维码对准镜头,识别成功后会自动读取借用记录。"
manual-placeholder="请输入资产编码"
@detected="handleCameraDetected"
/>
<!-- Return Info Form -->
<div v-if="borrowRecordInfo" class="card-apple">
<h2 class="text-apple-title mb-4">归还信息</h2>
<div class="space-y-4">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">归还状态</label>
<select v-model="returnInfo.return_condition" class="input-apple">
<section v-if="borrowRecordInfo" class="card">
<h3 class="section-title mb-4">归还信息</h3>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">归还状态</label>
<select v-model="returnInfo.return_condition" class="select">
<option value="正常">正常</option>
<option value="损坏">损坏</option>
<option value="丢失">丢失</option>
</select>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<div class="field-stack sm:col-span-2">
<label class="field-label">备注</label>
<textarea
v-model="returnInfo.notes"
class="input-apple"
class="input"
rows="3"
placeholder="请输入备注信息"
></textarea>
</div>
</div>
<div class="mobile-sticky-actions md:static md:m-0 md:border-0 md:bg-transparent md:p-0 mt-4">
<button
@click="handleSubmit"
:disabled="loading || !selectedBorrowRecord"
class="btn-apple-primary w-full"
class="btn-primary w-full text-sm"
>
{{ loading ? '提交中...' : '确认入库' }}
</button>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue'
import { useRouter } from 'vue-router'
import { getBorrowRecordByCode } from '@/api/borrow'
import { scanReturn } from '@/api/borrow'
import { getChildren } from '@/api/mount'
import { formatCurrency, formatDateTime, playBeepSound, playErrorSound } from '@/utils/format'
import type { BorrowRecord, StorageLocation } from '@/types'
import type { ChildEquipment } from '@/api/mount'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
interface ChildSnapshot {
id: number
@ -223,14 +338,16 @@ interface BorrowRecordInfo {
message: string
}
const router = useRouter()
const scanCode = ref('')
const scanInputRef = ref<HTMLInputElement>()
const showCameraScanner = ref(false)
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
const borrowRecordInfo = ref<BorrowRecordInfo | null>(null)
const loading = ref(false)
const childrenSnapshot = ref<ChildSnapshot[]>([])
const currentChildren = ref<ChildEquipment[]>([])
const pageNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
const returnSuccessCount = ref(0)
const returnInfo = ref({
return_condition: '正常',
@ -273,6 +390,19 @@ const isChildNew = (childId: number) => {
return !childrenSnapshot.value.some(c => c.id === childId)
}
const getStatusBadgeClass = (status: string) => {
switch (status) {
case '已借出':
return 'badge-warning'
case '已归还':
return 'badge-success'
case '逾期':
return 'badge-error'
default:
return 'badge-neutral'
}
}
const handleRecordSelect = () => {
// 解析选中记录的配件快照
if (selectedBorrowRecord.value && (selectedBorrowRecord.value as any).children_snapshot) {
@ -286,10 +416,42 @@ const handleRecordSelect = () => {
}
}
const selectBorrowRecord = (recordId: number) => {
selectedBorrowRecordId.value = String(recordId)
handleRecordSelect()
}
const focusScanInput = () => {
nextTick(() => {
scanInputRef.value?.focus()
})
}
const resetReturnForm = () => {
returnInfo.value = {
return_condition: '正常',
notes: ''
}
}
const resetCurrentFlow = () => {
borrowRecordInfo.value = null
selectedBorrowRecordId.value = ''
childrenSnapshot.value = []
currentChildren.value = []
scanCode.value = ''
resetReturnForm()
}
const handleFocus = () => {
scanCode.value = ''
}
const handleCameraDetected = async (value: string) => {
scanCode.value = value.trim()
await handleScan()
}
const handleScan = async () => {
if (!scanCode.value) {
return
@ -299,15 +461,11 @@ const handleScan = async () => {
const response = await getBorrowRecordByCode(scanCode.value)
if (response.data) {
borrowRecordInfo.value = response.data
selectedBorrowRecordId.value = ''
childrenSnapshot.value = []
currentChildren.value = []
returnInfo.value = {
return_condition: '正常',
notes: ''
}
resetReturnForm()
playBeepSound()
// 从借用记录中获取 equipment_id 来查询当前挂载的子资产
const equipmentId = response.data.borrow_records?.[0]?.equipment_id
if (equipmentId) {
@ -320,17 +478,33 @@ const handleScan = async () => {
// 忽略错误
}
}
// 清空扫码框,准备下次扫码
if (response.data.borrow_records.length === 1) {
selectedBorrowRecordId.value = String(response.data.borrow_records[0].id)
handleRecordSelect()
pageNotice.value = {
type: 'success',
message: `已读取 ${response.data.equipment_name} 的借用记录,可以直接确认归还。`
}
} else {
selectedBorrowRecordId.value = ''
pageNotice.value = {
type: 'warning',
message: `已读取 ${response.data.equipment_name},请先选择正确的归还使用者。`
}
}
scanCode.value = ''
nextTick(() => {
scanInputRef.value?.focus()
})
focusScanInput()
}
} catch (error: any) {
playErrorSound()
alert(error.message || '扫码失败,请检查资产编码是否正确')
pageNotice.value = {
type: 'error',
message: error.message || '扫码失败,请检查资产编码是否正确'
}
scanCode.value = ''
focusScanInput()
}
}
@ -342,7 +516,10 @@ const handleSubmit = async () => {
const record = selectedBorrowRecord.value
if (!record) {
alert('请选择归还使用者')
pageNotice.value = {
type: 'warning',
message: '请选择归还使用者后再确认入库。'
}
return
}
@ -382,19 +559,28 @@ const handleSubmit = async () => {
confirm_children_diff: true
})
}
alert('入库成功!')
router.push('/borrow')
playBeepSound()
returnSuccessCount.value += 1
pageNotice.value = {
type: 'success',
message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。`
}
resetCurrentFlow()
focusScanInput()
} catch (error: any) {
alert(error.message || '入库失败')
playErrorSound()
pageNotice.value = {
type: 'error',
message: error.message || '入库失败,请稍后再试'
}
focusScanInput()
} finally {
loading.value = false
}
}
onMounted(() => {
nextTick(() => {
scanInputRef.value?.focus()
})
focusScanInput()
})
</script>

View File

@ -1,228 +1,247 @@
<template>
<div class="relative space-y-6">
<Transition name="fade">
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center rounded-[24px] bg-white/70 backdrop-blur-sm"
>
<div class="flex flex-col items-center space-y-3 text-system-label-secondary">
<svg class="h-8 w-8 animate-spin text-[color:var(--theme-primary)]" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span class="text-apple-footnote">正在整理今日资产运行数据…</span>
</div>
<div class="space-y-6">
<!-- 加载状态 -->
<div
v-if="isLoading"
class="flex items-center justify-center py-20"
>
<div class="flex flex-col items-center space-y-3 text-text-secondary">
<svg class="h-8 w-8 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span class="text-sm">加载中...</span>
</div>
</Transition>
<section class="page-header">
<div>
<p class="page-kicker">Overview</p>
<h2 class="page-title">资产运行总览</h2>
<p class="page-subtitle">
把库存、借还、价值和近期流转集中在一个屏幕里,方便每天先看健康度,再决定去哪个模块处理问题。
</p>
<div class="mt-5 flex flex-wrap gap-3">
<router-link to="/borrow/scan" class="btn-apple-primary">扫码出库</router-link>
<router-link to="/return/scan" class="btn-apple-secondary">扫码入库</router-link>
<router-link v-if="isAdmin" to="/equipment" class="btn-apple-secondary">进入资产管理</router-link>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">今日借出</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_borrow || 0) }}</p>
<p class="page-meta__subvalue">流转价值 {{ formatCurrencyNoFraction(stats?.today_borrow_value || 0) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">今日归还</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_return || 0) }}</p>
<p class="page-meta__subvalue">回库价值 {{ formatCurrencyNoFraction(stats?.today_return_value || 0) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">数量利用率</p>
<p class="page-meta__value">{{ formatPercent(stats?.usage_rate || 0) }}</p>
<p class="page-meta__subvalue">价值利用率 {{ formatPercent(stats?.value_usage_rate || 0) }}</p>
</div>
</div>
</section>
<div class="metric-grid">
<StatCard
v-for="card in statCards"
:key="card.title"
:title="card.title"
:value="card.value"
:icon="card.icon"
:color="card.color"
:subtitle="card.subtitle"
/>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr,0.9fr]">
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">资产状态分布</h3>
<p class="section-subtitle">快速看出可用、借出和维护中的库存结构。</p>
<template v-else>
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Overview</p>
<h2 class="page-title">资产运行总览</h2>
<div class="mt-5 flex flex-wrap gap-3">
<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>
</div>
<span class="chip-muted">按库存数量</span>
</div>
<div ref="statusChartRef" class="mt-5 h-72"></div>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">可用</p>
<p class="mt-2 text-lg font-semibold text-apple-green">{{ formatNumber(stats?.available_equipment || 0) }}</p>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">今日借出</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_borrow || 0) }}</p>
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_borrow_value || 0) }}</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">借出</p>
<p class="mt-2 text-lg font-semibold text-apple-orange">{{ formatNumber(stats?.borrowed_equipment || 0) }}</p>
<div class="page-meta">
<p class="page-meta__label">今日归还</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_return || 0) }}</p>
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_return_value || 0) }}</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">维护</p>
<p class="mt-2 text-lg font-semibold text-apple-yellow">{{ formatNumber(stats?.maintenance_equipment || 0) }}</p>
<div class="page-meta">
<p class="page-meta__label">利用率</p>
<p class="page-meta__value">{{ formatPercent(stats?.usage_rate || 0) }}</p>
<p class="page-meta__subvalue">价值 {{ formatPercent(stats?.value_usage_rate || 0) }}</p>
</div>
</div>
</section>
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">库存结构快照</h3>
<p class="section-subtitle">优先显示库存占比较高的资产类型。</p>
<!-- 统计卡片 -->
<div class="grid gap-3 sm:gap-4 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">
<p class="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary truncate">{{ card.title }}</p>
<p class="mt-2 text-lg sm:text-2xl font-semibold tracking-tight text-text-primary truncate">{{ card.value }}</p>
</div>
<div class="flex h-8 w-8 sm:h-10 sm:w-10 shrink-0 items-center justify-center rounded-lg bg-accent-primary/10">
<component :is="card.icon" class="h-4 w-4 sm:h-5 sm:w-5 text-accent-primary" />
</div>
</div>
<span class="chip-muted">按资产类型</span>
<p class="mt-2 text-[10px] sm:text-xs text-text-secondary truncate">{{ card.subtitle }}</p>
</div>
<div ref="categoryChartRef" class="mt-5 h-72"></div>
<div class="mt-4 space-y-3">
<div
v-for="item in topCategories"
:key="item.equipment_type"
class="panel-muted flex items-center justify-between gap-4"
>
<div>
<p class="text-sm font-semibold text-system-label-primary">{{ item.equipment_type }}</p>
<p class="mt-1 text-xs text-system-label-secondary">
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</div>
<!-- 图表区域 -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- 资产状态分布 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">资产状态分布</h3>
<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="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>
</div>
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">借出</p>
<p class="text-lg font-semibold text-status-warning mt-1">{{ formatNumber(stats?.borrowed_equipment || 0) }}</p>
</div>
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">维护</p>
<p class="text-lg font-semibold text-status-error mt-1">{{ formatNumber(stats?.maintenance_equipment || 0) }}</p>
</div>
</div>
</section>
<!-- 库存结构 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">库存结构</h3>
<span class="badge badge-neutral">TOP 5</span>
</div>
<div ref="categoryChartRef" class="h-64"></div>
<div class="mt-4 space-y-2">
<div
v-for="item in topCategories"
:key="item.equipment_type"
class="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div>
<p class="text-sm font-medium text-text-primary">{{ item.equipment_type }}</p>
<p class="text-xs text-text-tertiary">
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</p>
</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>
</div>
</section>
</div>
<!-- 最近借用记录 -->
<div class="grid gap-6 lg:grid-cols-3">
<section class="card lg:col-span-2">
<div class="list-header">
<h3 class="section-title">最近借用记录</h3>
<router-link to="/borrow" class="btn-secondary text-sm">查看全部</router-link>
</div>
<!-- 移动端:卡片列表 -->
<div class="mobile-card-list md:hidden">
<article v-for="record in recentRecords" :key="record.id" class="mobile-card">
<div class="mobile-card__row">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text-primary truncate">{{ record.equipment_name }}</p>
<p class="text-xs text-text-tertiary font-mono">{{ record.equipment_code }}</p>
</div>
<span class="badge" :class="getStatusBadgeClass(record.status)">
{{ record.status }}
</span>
</div>
<div class="mobile-card__meta mt-3">
<div>
<p class="mobile-card__label">使用者</p>
<p class="mobile-card__value">{{ record.person_nickname }}</p>
</div>
<div>
<p class="mobile-card__label">位置</p>
<p class="mobile-card__value truncate">{{ record.storageLocation?.name || '-' }}</p>
</div>
<div class="col-span-2">
<p class="mobile-card__label">借用时间</p>
<p class="mobile-card__value">{{ formatDateTime(record.borrow_time) }}</p>
</div>
</div>
</article>
<div v-if="recentRecords.length === 0" class="empty-state">
暂无借用记录
</div>
</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>
</tr>
</thead>
<tbody>
<tr v-for="record in recentRecords" :key="record.id">
<td>
<p class="font-medium text-text-primary">{{ record.equipment_name }}</p>
<p class="text-xs text-text-tertiary font-mono">{{ record.equipment_code }}</p>
</td>
<td>
<p class="text-text-primary">{{ record.person_nickname }}</p>
</td>
<td>
<p class="text-text-secondary">{{ record.storageLocation?.name || '-' }}</p>
</td>
<td class="text-text-secondary text-sm">
{{ formatDateTime(record.borrow_time) }}
</td>
<td>
<span
class="badge"
:class="getStatusBadgeClass(record.status)"
>
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="recentRecords.length === 0">
<td colspan="5" class="text-center text-text-secondary py-8">暂无借用记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 运营提醒 -->
<section class="card">
<h3 class="section-title mb-5">运营概览</h3>
<div class="space-y-4">
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">总资产价值</p>
<p class="text-2xl font-semibold text-text-primary mt-1">
{{ formatCurrencyNoFraction(stats?.total_value || 0) }}
</p>
</div>
<div class="text-right">
<p class="text-lg font-semibold text-system-label-primary">{{ formatNumber(item.total_quantity) }}</p>
<p class="text-xs text-system-label-tertiary">总库存</p>
<div class="panel">
<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>
<p class="text-xs text-text-secondary mt-1">
价值 {{ 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">
{{ formatNumber(stats?.borrowed_equipment || 0) }} 件
</p>
<p class="text-xs text-text-secondary mt-1">
价值 {{ 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">
{{ formatNumber(stats?.maintenance_equipment || 0) }} 件
</p>
<p class="text-xs text-text-secondary mt-1">
价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}
</p>
</div>
</div>
<div v-if="topCategories.length === 0" class="panel-muted text-sm text-system-label-secondary">
暂无可展示的分类库存数据。
</div>
</div>
</section>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[1.35fr,0.85fr]">
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">最近借用记录</h3>
<p class="section-subtitle">把资产、使用者、地点和归还计划放在一张表里,减少来回点开。</p>
</div>
<router-link to="/borrow" class="btn-apple-secondary text-sm">查看全部记录</router-link>
</div>
<div class="table-shell mt-5">
<div class="overflow-x-auto">
<table class="data-table min-w-[860px]">
<thead>
<tr>
<th class="text-left">资产</th>
<th class="text-left">使用者</th>
<th class="text-left">位置</th>
<th class="text-left">借用时间</th>
<th class="text-left">归还计划</th>
<th class="text-left">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in recentRecords" :key="record.id">
<td>
<p class="font-medium text-system-label-primary">{{ record.equipment_name }}</p>
<p class="mt-1 font-mono text-xs text-system-label-tertiary">{{ record.equipment_code }}</p>
</td>
<td>
<p class="font-medium text-system-label-primary">{{ record.person_nickname }}</p>
<p class="mt-1 text-xs text-system-label-secondary">{{ record.person_name }}</p>
</td>
<td>
<p class="text-sm text-system-label-primary">{{ record.storageLocation?.name || '-' }}</p>
<p v-if="record.storageLocation?.code" class="mt-1 text-xs text-system-label-tertiary">
{{ record.storageLocation.code }}
</p>
</td>
<td class="text-sm text-system-label-secondary">{{ formatDateTime(record.borrow_time) }}</td>
<td class="text-sm text-system-label-secondary">
{{
record.is_long_term
? '在职长期'
: record.expected_return_time
? formatDateTime(record.expected_return_time)
: '-'
}}
</td>
<td>
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-medium"
:class="getStatusClass(record.status)"
>
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="recentRecords.length === 0">
<td colspan="6" class="text-center text-sm text-system-label-secondary">最近暂无借用记录。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="card-apple">
<div>
<h3 class="section-title">运营提醒</h3>
<p class="section-subtitle">给管理者一个可以快速判断是否异常的短面板。</p>
</div>
<div class="mt-5 space-y-3">
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">总资产价值</p>
<p class="mt-2 text-2xl font-semibold text-system-label-primary">
{{ formatCurrencyNoFraction(stats?.total_value || 0) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">
可用价值 {{ formatCurrencyNoFraction(stats?.available_value || 0) }}
</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">借出压力</p>
<p class="mt-2 text-lg font-semibold text-system-label-primary">
{{ formatNumber(stats?.borrowed_equipment || 0) }} 件在外
</p>
<p class="mt-2 text-sm text-system-label-secondary">
对应价值 {{ formatCurrencyNoFraction(stats?.borrowed_value || 0) }}
</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">维护提醒</p>
<p class="mt-2 text-lg font-semibold text-system-label-primary">
{{ formatNumber(stats?.maintenance_equipment || 0) }} 件处理中
</p>
<p class="mt-2 text-sm text-system-label-secondary">
当前维护价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}
</p>
</div>
</div>
</section>
</div>
</section>
</div>
</template>
</div>
</template>
@ -234,7 +253,6 @@ import { getDashboardStats } from '@/api/statistics'
import { getBorrowRecordList } from '@/api/borrow'
import type { DashboardStats, BorrowRecord } from '@/types'
import { formatCurrencyNoFraction, formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import StatCard from '@/components/StatCard.vue'
import {
BanknotesIcon,
ChartBarSquareIcon,
@ -262,42 +280,42 @@ const statCards = computed(() => {
title: '资产库存总量',
value: formatNumber(data?.total_equipment || 0),
icon: CubeIcon,
color: 'apple-blue',
subtitle: `总资产价值 ${formatCurrencyNoFraction(data?.total_value || 0)}`
color: 'primary' as const,
subtitle: `总价值 ${formatCurrencyNoFraction(data?.total_value || 0)}`
},
{
title: '可用库存',
value: formatNumber(data?.available_equipment || 0),
icon: CheckCircleIcon,
color: 'apple-green',
color: 'success' as const,
subtitle: `可用价值 ${formatCurrencyNoFraction(data?.available_value || 0)}`
},
{
title: '已借出库存',
value: formatNumber(data?.borrowed_equipment || 0),
icon: ArrowPathRoundedSquareIcon,
color: 'apple-orange',
color: 'warning' as const,
subtitle: `借出价值 ${formatCurrencyNoFraction(data?.borrowed_value || 0)}`
},
{
title: '维护中库存',
value: formatNumber(data?.maintenance_equipment || 0),
icon: WrenchScrewdriverIcon,
color: 'apple-yellow',
color: 'error' as const,
subtitle: `维护价值 ${formatCurrencyNoFraction(data?.maintenance_value || 0)}`
},
{
title: '今日借出',
value: formatNumber(data?.today_borrow || 0),
icon: ChartBarSquareIcon,
color: 'apple-blue',
color: 'primary' as const,
subtitle: `数量利用率 ${formatPercent(data?.usage_rate || 0)}`
},
{
title: '今日归还',
value: formatNumber(data?.today_return || 0),
icon: BanknotesIcon,
color: 'apple-green',
color: 'success' as const,
subtitle: `价值利用率 ${formatPercent(data?.value_usage_rate || 0)}`
}
]
@ -352,24 +370,22 @@ const updateStatusChart = () => {
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#6b7280'
}
textStyle: { color: '#6e6e6e' }
},
series: [
{
type: 'pie',
radius: ['46%', '74%'],
radius: ['45%', '70%'],
center: ['50%', '45%'],
padAngle: 2,
padAngle: 3,
label: {
formatter: '{b}\n{d}%',
color: '#5f6675'
color: '#a1a1a1'
},
data: [
{ value: stats.value.available_equipment || 0, name: '可用', itemStyle: { color: '#34C759' } },
{ value: stats.value.borrowed_equipment || 0, name: '已借出', itemStyle: { color: '#FF9500' } },
{ value: stats.value.maintenance_equipment || 0, name: '维护中', itemStyle: { color: '#FFCC00' } }
{ value: stats.value.available_equipment || 0, name: '可用', itemStyle: { color: '#10a37f' } },
{ value: stats.value.borrowed_equipment || 0, name: '已借出', itemStyle: { color: '#f59e0b' } },
{ value: stats.value.maintenance_equipment || 0, name: '维护中', itemStyle: { color: '#ef4444' } }
]
}
]
@ -394,11 +410,14 @@ const updateCategoryChart = () => {
},
xAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(99, 102, 112, 0.08)' } }
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#6e6e6e' }
},
yAxis: {
type: 'category',
axisTick: { show: false },
axisLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#a1a1a1' },
data: categories.map((item) => item.equipment_type)
},
series: [
@ -408,8 +427,8 @@ const updateCategoryChart = () => {
data: categories.map((item, index) => ({
value: item.total_quantity,
itemStyle: {
borderRadius: 999,
color: ['#355C7D', '#4D8076', '#C06C84', '#F08A5D', '#8F6BB3'][index % 5]
borderRadius: [0, 8, 8, 0],
color: ['#10a37f', '#0d8c6d', '#19c59f', '#f59e0b', '#3b82f6'][index % 5]
}
}))
}
@ -417,13 +436,13 @@ const updateCategoryChart = () => {
})
}
const getStatusClass = (status: string) => {
const getStatusBadgeClass = (status: string) => {
const classes: Record<string, string> = {
'已借出': 'bg-apple-orange/15 text-apple-orange',
'已归还': 'bg-apple-green/15 text-apple-green',
'逾期': 'bg-apple-red/15 text-apple-red'
'已借出': 'badge-warning',
'已归还': 'badge-success',
'逾期': 'badge-error'
}
return classes[status] || 'bg-system-gray-5 text-system-label-secondary'
return classes[status] || 'badge-neutral'
}
const resizeHandler = () => {
@ -443,15 +462,3 @@ onUnmounted(() => {
window.removeEventListener('resize', resizeHandler)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,316 @@
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Inbox</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">{{ notificationStore.unreadCount }}</p>
<p class="page-meta__subvalue">待行政查看</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前筛选</p>
<p class="page-meta__value">{{ statusFilter === 'unread' ? '仅未读' : '全部消息' }}</p>
<p class="page-meta__subvalue">按同步结果自动沉淀</p>
</div>
</div>
</section>
<!-- 筛选 -->
<section class="card">
<div class="filter-bar">
<div class="filter-bar__inputs">
<div class="field-stack">
<label class="field-label">消息范围</label>
<div class="segmented-toggle">
<button
type="button"
class="segmented-toggle__item"
:class="{ 'is-active': statusFilter === 'all' }"
@click="switchFilter('all')"
>
全部消息
</button>
<button
type="button"
class="segmented-toggle__item"
:class="{ 'is-active': statusFilter === 'unread' }"
@click="switchFilter('unread')"
>
仅看未读
</button>
</div>
</div>
</div>
<div class="filter-bar__actions">
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchNotifications">
{{ loading ? '刷新中...' : '刷新' }}
</button>
<button
class="btn-primary text-xs sm:text-sm"
:disabled="markAllLoading || notificationStore.unreadCount === 0"
@click="handleMarkAllRead"
>
{{ markAllLoading ? '处理中...' : '全标已读' }}
</button>
</div>
</div>
</section>
<!-- 消息列表 -->
<section class="space-y-4">
<article
v-for="item in notifications"
:key="item.id"
class="card"
:class="item.is_read ? 'border-border' : 'border-accent-primary/30'"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="badge"
:class="item.is_read ? 'badge-neutral' : 'badge-success'"
>
{{ item.is_read ? '已读' : '未读' }}
</span>
<span
class="badge"
:class="getLevelBadgeClass(item.level)"
>
{{ getLevelLabel(item.level) }}
</span>
<span class="text-xs text-text-tertiary">{{ formatDateTime(item.notification_created_at) }}</span>
</div>
<h3 class="mt-3 text-lg font-semibold text-text-primary">{{ item.title }}</h3>
<p v-if="item.content" class="mt-3 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ item.content }}
</p>
<div
v-if="summaryPairs(item).length > 0"
class="mt-4 flex flex-wrap gap-2"
>
<span
v-for="pair in summaryPairs(item)"
:key="pair.label"
class="badge badge-neutral"
>
{{ pair.label }} {{ pair.count }}
</span>
</div>
<div
v-if="item.metadata?.preview_changes?.length"
class="mt-4 panel"
>
<p class="text-sm font-medium text-text-primary">变化预览</p>
<div class="mt-3 space-y-3">
<div
v-for="change in item.metadata.preview_changes"
:key="`${item.id}-${change.title}`"
>
<p class="text-sm font-medium text-text-primary">{{ change.title }}</p>
<p class="mt-1 text-sm text-text-secondary">{{ change.detail }}</p>
</div>
</div>
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col">
<button
v-if="!item.is_read"
class="btn-secondary w-full text-sm"
@click="handleMarkRead(item)"
>
标记已读
</button>
<button
v-if="item.action_url || item.related_sync_job_id"
class="btn-primary w-full text-sm"
@click="openAction(item)"
>
{{ item.related_sync_job_id ? '查看同步详情' : '查看同步页' }}
</button>
</div>
</div>
</article>
<div v-if="!loading && notifications.length === 0" class="empty-state">
当前没有{{ statusFilter === 'unread' ? '未读' : '' }}站内消息。
</div>
</section>
<!-- 分页 -->
<section v-if="total > 0" class="card">
<div class="pagination-bar">
<p class="text-sm text-text-tertiary">
第 {{ currentPage }} / {{ totalPages }} 页,共 {{ total }} 条消息
</p>
<div class="flex 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>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { formatDateTime } from '@/utils/format'
import type { NotificationItem } from '@/types'
import {
getNotificationList,
markAllNotificationsAsRead,
markNotificationAsRead
} from '@/api/notifications'
import { useNotificationStore } from '@/stores/notifications'
const router = useRouter()
const notificationStore = useNotificationStore()
const notifications = ref<NotificationItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 12
const statusFilter = ref<'all' | 'unread'>('all')
const loading = ref(false)
const markAllLoading = ref(false)
const CHANGE_TYPE_LABELS: Record<string, string> = {
person_created: '新人入职',
person_deactivated: '人员离职/停用',
person_reactivated: '重新启用',
person_main_department_changed: '主部门变动',
person_departments_changed: '关联部门变动',
person_position_changed: '岗位变动',
department_created: '新增部门',
department_renamed: '部门名称变动',
department_hierarchy_changed: '部门层级变动',
department_deactivated: '部门停用',
department_reactivated: '部门重新启用'
}
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const getLevelLabel = (level?: string) => {
if (level === 'error') return '异常'
if (level === 'warning') return '提醒'
return '信息'
}
const getLevelBadgeClass = (level?: string) => {
if (level === 'error') return 'badge-error'
if (level === 'warning') return 'badge-warning'
return 'badge-neutral'
}
const summaryPairs = (item: NotificationItem) =>
Object.entries(item.metadata?.counts || {})
.filter(([, count]) => Number(count) > 0)
.map(([key, count]) => ({
label: CHANGE_TYPE_LABELS[key] || key,
count: Number(count)
}))
const fetchNotifications = async () => {
loading.value = true
try {
const response = await getNotificationList({
page: currentPage.value,
pageSize,
status: statusFilter.value
})
if (response.data) {
notifications.value = response.data.list
total.value = response.data.total
notificationStore.setUnreadCount(response.data.unread_count || 0)
}
} catch (error: any) {
alert(error?.message || '获取站内消息失败')
} finally {
loading.value = false
}
}
const switchFilter = (value: 'all' | 'unread') => {
statusFilter.value = value
currentPage.value = 1
fetchNotifications()
}
const handleMarkRead = async (item: NotificationItem) => {
try {
await markNotificationAsRead(item.id)
item.is_read = true
item.read_at = new Date().toISOString()
await notificationStore.fetchUnreadCount()
if (statusFilter.value === 'unread') {
fetchNotifications()
}
} catch (error: any) {
alert(error?.message || '标记已读失败')
}
}
const handleMarkAllRead = async () => {
markAllLoading.value = true
try {
await markAllNotificationsAsRead()
await notificationStore.fetchUnreadCount()
await fetchNotifications()
} catch (error: any) {
alert(error?.message || '全部标记已读失败')
} finally {
markAllLoading.value = false
}
}
const openAction = async (item: NotificationItem) => {
if (!item.is_read) {
await handleMarkRead(item)
}
if (item.related_sync_job_id) {
await router.push({
path: '/settings',
query: {
tab: 'sync',
syncJobId: String(item.related_sync_job_id)
}
})
return
}
await router.push(item.action_url || '/settings?tab=sync')
}
const handlePrevPage = () => {
if (currentPage.value <= 1) return
currentPage.value -= 1
fetchNotifications()
}
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) return
currentPage.value += 1
fetchNotifications()
}
onMounted(async () => {
await notificationStore.fetchUnreadCount()
await fetchNotifications()
})
</script>

View File

@ -1,34 +1,62 @@
<template>
<div class="space-y-6">
<div class="flex flex-wrap items-center gap-3">
<button
v-for="tab in tabs"
:key="tab.key"
class="rounded-apple px-4 py-2 text-apple-body transition-colors bg-system-gray-6 text-system-label-secondary hover:bg-system-gray-5 tab-button"
:class="activeTab === tab.key ? 'tab-button--active' : ''"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</button>
</div>
<section class="page-header">
<div>
<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>
<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>
</div>
</section>
<SiteSettings v-if="activeTab === 'site'" />
<UserManagement v-else-if="activeTab === 'users'" />
<PersonManagement v-else-if="activeTab === 'persons'" />
<StorageLocationManagement v-else-if="activeTab === 'locations'" />
<AssetTypeManagement v-else-if="activeTab === 'assetTypes'" />
<DepartmentManagement v-else-if="activeTab === 'departments'" />
<MaintenanceManagement v-else-if="activeTab === 'maintenance'" />
<DisposalManagement v-else-if="activeTab === 'disposal'" />
<OperationLogList v-else-if="activeTab === 'logs'" />
<section class="card">
<div class="overflow-x-auto">
<div class="segmented-toggle min-w-max">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="segmented-toggle__item"
:class="{ 'is-active': activeTab === tab.key }"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</button>
</div>
</div>
</section>
<div class="min-h-[400px]">
<SiteSettings v-if="activeTab === 'site'" />
<UserManagement v-else-if="activeTab === 'users'" />
<HrSyncManagement v-else-if="activeTab === 'sync'" />
<PersonManagement v-else-if="activeTab === 'persons'" />
<StorageLocationManagement v-else-if="activeTab === 'locations'" />
<AssetTypeManagement v-else-if="activeTab === 'assetTypes'" />
<DepartmentManagement v-else-if="activeTab === 'departments'" />
<MaintenanceManagement v-else-if="activeTab === 'maintenance'" />
<DisposalManagement v-else-if="activeTab === 'disposal'" />
<OperationLogList v-else-if="activeTab === 'logs'" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SiteSettings from './components/SiteSettings.vue'
import UserManagement from './components/UserManagement.vue'
import HrSyncManagement from './components/HrSyncManagement.vue'
import PersonManagement from './components/PersonManagement.vue'
import StorageLocationManagement from './components/StorageLocationManagement.vue'
import AssetTypeManagement from './components/AssetTypeManagement.vue'
@ -37,11 +65,22 @@ import MaintenanceManagement from './components/MaintenanceManagement.vue'
import DisposalManagement from './components/DisposalManagement.vue'
import OperationLogList from './components/OperationLogList.vue'
type TabKey = 'site' | 'users' | 'persons' | 'locations' | 'assetTypes' | 'departments' | 'maintenance' | 'disposal' | 'logs'
type TabKey =
| 'site'
| 'users'
| 'sync'
| 'persons'
| 'locations'
| 'assetTypes'
| 'departments'
| 'maintenance'
| 'disposal'
| 'logs'
const tabs: Array<{ key: TabKey; label: string }> = [
{ key: 'site', label: '站点信息' },
{ key: 'users', label: '用户管理' },
{ key: 'sync', label: '组织同步' },
{ key: 'persons', label: '使用者信息' },
{ key: 'locations', label: '地址管理' },
{ key: 'assetTypes', label: '资产类型' },
@ -56,6 +95,7 @@ const router = useRouter()
const initialTab = (route.query.tab as TabKey) || 'site'
const activeTab = ref<TabKey>(tabs.some((t) => t.key === initialTab) ? initialTab : 'site')
const activeTabLabel = computed(() => tabs.find((tab) => tab.key === activeTab.value)?.label || '站点信息')
const switchTab = (tab: TabKey) => {
activeTab.value = tab
@ -78,12 +118,3 @@ watch(
}
)
</script>
<style scoped>
.tab-button--active {
background-color: var(--theme-primary) !important;
color: #ffffff !important;
box-shadow: 0 6px 18px var(--theme-primary-ring);
}
</style>

View File

@ -1,69 +1,70 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="grid gap-4 md:grid-cols-3 md:items-end">
<div class="md:col-span-2">
<label class="block text-apple-body text-system-label-primary mb-2">搜索资产类型</label>
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<input
v-model.trim="filters.keyword"
class="input-apple"
class="input"
type="text"
placeholder="输入类型名称关键字"
@keyup.enter="fetchTypes"
/>
</div>
<div class="flex gap-3">
<select v-model="filters.status" class="input-apple">
<div class="flex flex-wrap gap-2 sm:gap-3">
<select v-model="filters.status" class="select">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchTypes">查询</button>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchTypes">查询</button>
</div>
</div>
</section>
<section class="card-apple">
<h2 class="text-apple-title mb-4">新增资产类型</h2>
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">类型名称 *</label>
<!-- 新增表单 -->
<section class="card">
<h3 class="section-title mb-4">新增资产类型</h3>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">类型名称 *</label>
<input
v-model.trim="createForm.name"
class="input-apple"
class="input"
type="text"
placeholder="例如:办公电脑、显示器"
/>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">简称(用于自动编码) *</label>
<div class="field-stack">
<label class="field-label">简称(用于自动编码) *</label>
<input
v-model.trim="createForm.short_name"
class="input-apple"
class="input"
type="text"
placeholder="例如:PC、MON"
maxlength="20"
/>
</div>
<div class="flex items-center gap-6 pt-6">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="createForm.can_be_parent" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-apple-body">可作为父资产</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="createForm.can_be_child" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-apple-body">可作为子资产</span>
</label>
<div class="field-stack">
<label class="field-label">类型属性</label>
<div class="flex items-center gap-6 pt-2">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="createForm.can_be_parent" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-sm text-text-secondary">可作为父资产</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="createForm.can_be_child" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-sm text-text-secondary">可作为子资产</span>
</label>
</div>
</div>
</div>
<p class="text-apple-footnote text-system-label-tertiary mt-2">
简称将用于自动生成资产编码,例如 `PC-0001-00001`。
</p>
<p class="text-apple-footnote text-system-label-tertiary mt-1">
父资产(如主机)可挂载子资产;子资产(如显卡、内存)可被挂载到父资产上
<p class="text-xs text-text-tertiary mt-3">
简称将用于自动生成资产编码,例如 `JS-PC-0001`。父资产(如主机)可挂载子资产;子资产(如显卡、内存)可被挂载到父资产上。
</p>
<button
class="btn-apple-primary mt-4"
class="btn-primary text-xs sm:text-sm mt-4"
:disabled="createLoading || !canCreate"
@click="handleCreate"
>
@ -71,123 +72,157 @@
</button>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<h2 class="text-apple-title">资产类型列表</h2>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchTypes">
<!-- 列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">资产类型列表</h3>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchTypes">
刷新
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">名称</th>
<th class="py-3 px-4">简称</th>
<th class="py-3 px-4">父/子资产</th>
<th class="py-3 px-4">状态</th>
<th class="py-3 px-4">当前资产数量</th>
<th class="py-3 px-4 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="assetType in types"
:key="assetType.id"
class="border-b border-system-gray-6 text-system-label-primary text-apple-body"
<!-- 移动端卡片列表 -->
<div class="mobile-card-list md:hidden">
<div v-for="assetType in types" :key="assetType.id" class="mobile-card">
<div class="flex items-start justify-between">
<div>
<p class="font-medium text-text-primary">{{ assetType.name }}</p>
<p class="text-sm text-text-tertiary">
更新于 {{ formatDateTime(assetType.updated_at) }}
</p>
</div>
<span
class="badge"
:class="assetType.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
<td class="py-3 px-4">
<div class="font-medium">{{ assetType.name }}</div>
<div class="text-apple-footnote text-system-label-tertiary">
更新于 {{ formatDateTime(assetType.updated_at) }}
</div>
</td>
<td class="py-3 px-4">
<span v-if="assetType.short_name" class="font-mono text-apple-blue">{{ assetType.short_name }}</span>
<span v-else class="text-system-label-tertiary">-</span>
</td>
<td class="py-3 px-4">
<div class="flex gap-2">
<span v-if="assetType.can_be_parent" class="rounded-apple px-2 py-0.5 text-apple-footnote bg-apple-blue/15 text-apple-blue">父</span>
<span v-if="assetType.can_be_child" class="rounded-apple px-2 py-0.5 text-apple-footnote bg-apple-purple/15 text-apple-purple">子</span>
<span v-if="!assetType.can_be_parent && !assetType.can_be_child" class="text-system-label-tertiary">-</span>
</div>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="assetType.status === 'active' ? 'bg-apple-green/15 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ assetType.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td class="py-3 px-4 text-apple-body">
{{ assetType._count?.equipments ?? 0 }}
</td>
<td class="py-3 px-4 text-right space-x-3">
<button class="text-apple-blue hover:underline" @click="openEdit(assetType)">
编辑
</button>
<button
class="text-apple-orange hover:underline"
@click="toggleStatus(assetType)"
>
{{ assetType.status === 'active' ? '停用' : '启用' }}
</button>
<button
class="text-apple-red hover:underline"
@click="handleDelete(assetType)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
{{ assetType.status === 'active' ? '启用' : '停用' }}
</span>
</div>
<div class="mt-3 text-sm text-text-secondary space-y-1">
<p>简称: <span class="font-mono text-accent">{{ assetType.short_name || '-' }}</span></p>
<p>资产数量: {{ assetType._count?.equipments ?? 0 }}</p>
<div class="flex gap-2 mt-2">
<span v-if="assetType.can_be_parent" class="badge badge-success">父资产</span>
<span v-if="assetType.can_be_child" class="badge badge-warning">子资产</span>
</div>
</div>
<div class="mt-3 table-action-group">
<button class="table-action table-action--primary" @click="openEdit(assetType)">编辑</button>
<button class="table-action table-action--warning" @click="toggleStatus(assetType)">
{{ assetType.status === 'active' ? '停用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDelete(assetType)">删除</button>
</div>
</div>
</div>
<p v-if="!loading && types.length === 0" class="mt-6 text-center text-system-label-tertiary">
<!-- 桌面端表格 -->
<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 class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="assetType in types" :key="assetType.id">
<td>
<div class="font-medium">{{ assetType.name }}</div>
<div class="text-xs text-text-tertiary">
更新于 {{ formatDateTime(assetType.updated_at) }}
</div>
</td>
<td>
<span v-if="assetType.short_name" class="font-mono text-accent">{{ assetType.short_name }}</span>
<span v-else class="text-text-tertiary">-</span>
</td>
<td>
<div class="flex gap-2">
<span v-if="assetType.can_be_parent" class="badge badge-success">父</span>
<span v-if="assetType.can_be_child" class="badge badge-warning">子</span>
<span v-if="!assetType.can_be_parent && !assetType.can_be_child" class="text-text-tertiary">-</span>
</div>
</td>
<td>
<span
class="badge"
:class="assetType.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ assetType.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td>
{{ assetType._count?.equipments ?? 0 }}
</td>
<td class="text-right">
<div class="table-action-group justify-end">
<button class="table-action table-action--primary" @click="openEdit(assetType)">
编辑
</button>
<button class="table-action table-action--warning" @click="toggleStatus(assetType)">
{{ assetType.status === 'active' ? '停用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDelete(assetType)">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-if="!loading && types.length === 0" class="mt-6 text-center text-text-tertiary">
暂无资产类型数据。
</p>
</section>
<div
v-if="editingType"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4"
>
<div class="w-full max-w-xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">编辑资产类型</h3>
<!-- 编辑弹窗 -->
<div v-if="editingType" 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="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">类型名称 *</label>
<input v-model.trim="editDraft.name" class="input-apple" type="text" />
<div class="field-grid">
<div class="field-stack">
<label class="field-label">类型名称 *</label>
<input v-model.trim="editDraft.name" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">简称(用于标签编码)</label>
<input v-model.trim="editDraft.short_name" class="input-apple" type="text" placeholder="例如:PC、MON" maxlength="20" />
<div class="field-stack">
<label class="field-label">简称(用于标签编码)</label>
<input v-model.trim="editDraft.short_name" class="input" type="text" placeholder="例如:PC、MON" maxlength="20" />
</div>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="editDraft.status" class="input-apple">
<div class="field-stack">
<label class="field-label">状态</label>
<select v-model="editDraft.status" class="select">
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
</div>
<div class="flex items-center gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="editDraft.can_be_parent" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-apple-body">可作为父资产</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="editDraft.can_be_child" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-apple-body">可作为子资产</span>
</label>
<div class="field-stack">
<label class="field-label">类型属性</label>
<div class="flex items-center gap-6 pt-2">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="editDraft.can_be_parent" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-sm text-text-secondary">可作为父资产</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="editDraft.can_be_child" type="checkbox" class="w-4 h-4 rounded" />
<span class="text-sm text-text-secondary">可作为子资产</span>
</label>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button class="btn-apple-secondary" @click="closeEdit">取消</button>
<button class="btn-apple-primary" :disabled="updateLoading" @click="submitEdit">
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeEdit">取消</button>
<button class="btn-primary" :disabled="updateLoading" @click="submitEdit">
{{ updateLoading ? '保存中...' : '保存修改' }}
</button>
</div>

View File

@ -1,214 +1,261 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="rounded-apple bg-apple-blue/10 px-4 py-3 text-apple-footnote text-system-label-secondary">
固定资产系统的部门会按“正式员工所在部门 + 上级链路”同步,这里展示的是可用于资产归属的部门树,不再按源库全量组织直接照搬。
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Department Directory</p>
<h2 class="page-title">使用部门</h2>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-3 md:items-end">
<div class="md:col-span-2">
<label class="block text-apple-body text-system-label-primary mb-2">搜索使用部门</label>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">部门总数</p>
<p class="page-meta__value">{{ stats.total }}</p>
<p class="page-meta__subvalue">当前显示 {{ displayedDepartments.length }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">启用部门</p>
<p class="page-meta__value text-status-success">{{ stats.active }}</p>
<p class="page-meta__subvalue">停用 {{ stats.inactive }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">在用部门</p>
<p class="page-meta__value text-accent">{{ stats.inUse }}</p>
<p class="page-meta__subvalue">含资产或使用人</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已同步</p>
<p class="page-meta__value">{{ stats.synced }}</p>
<p class="page-meta__subvalue">本地补录 {{ stats.local }}</p>
</div>
</div>
</section>
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<input
v-model.trim="filters.keyword"
class="input-apple"
class="input"
type="text"
placeholder="输入部门名称关键字"
@keyup.enter="fetchDepartments"
placeholder="搜索部门名称"
/>
</div>
<div class="flex gap-3">
<select v-model="filters.status" class="input-apple">
<select v-model="filters.status" class="select">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchDepartments">查询</button>
</div>
</div>
</section>
<section class="card-apple">
<h2 class="text-apple-title mb-4">新增使用部门</h2>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">部门名称 *</label>
<input
v-model.trim="createForm.name"
class="input-apple"
type="text"
placeholder="例如:市场部、技术部"
/>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">上级部门</label>
<select v-model="createForm.parent_id" class="input-apple">
<option :value="null">无(作为一级部门)</option>
<option v-for="dept in parentOptions" :key="dept.id" :value="dept.id">
{{ dept.optionLabel }}
</option>
<select v-model="filters.focus" class="select">
<option value="all">全部部门</option>
<option value="inUse">仅在用</option>
<option value="branch">仅有下级</option>
<option value="leaf">仅叶子部门</option>
<option value="synced">仅已同步</option>
<option value="local">仅本地补录</option>
</select>
<p class="text-apple-footnote text-system-label-tertiary mt-1">
可挂到任意已启用部门下,支持多级结构
</p>
</div>
</div>
<button
class="btn-apple-primary mt-4"
:disabled="createLoading || !canCreate"
@click="handleCreate"
>
{{ createLoading ? '提交中...' : '新增部门' }}
</button>
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-border">
<button class="btn-secondary text-xs sm:text-sm" @click="handleResetFilters">重置</button>
<button class="btn-secondary text-xs sm:text-sm" @click="goToSync">同步</button>
<button
class="btn-secondary text-xs sm:text-sm"
:disabled="loading || expandableIds.length === 0"
@click="toggleExpandAll"
>
{{ hasCollapsedNodes ? '展开' : '收起' }}
</button>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchDepartments">刷新</button>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<span class="badge badge-neutral">默认优先查看启用部门</span>
<span v-if="filters.keyword" class="badge badge-neutral">关键词:{{ filters.keyword }}</span>
<span v-if="filters.focus !== 'all'" class="badge badge-neutral">视图:{{ focusLabel }}</span>
</div>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-apple-title">使用部门列表</h2>
<p class="mt-1 text-apple-footnote text-system-label-tertiary">
当前共 {{ flatDepartments.length }} 个部门节点,默认折叠显示
</p>
<!-- 主体内容 -->
<section class="grid gap-6 xl:grid-cols-[minmax(0,1.6fr),360px]">
<!-- 部门列表 -->
<section class="card">
<div class="list-header">
<div>
<h3 class="section-title">部门列表</h3>
<p class="mt-1 text-sm text-text-secondary">
展开节点 {{ expandedIds.size }} / {{ expandableIds.length }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn-apple-secondary" :disabled="loading" @click="expandAll">
全部展开
</button>
<button class="btn-apple-secondary" :disabled="loading" @click="collapseAll">
全部收起
</button>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchDepartments">
刷新
</button>
<div v-if="loading" class="empty-state">
正在加载部门数据...
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">名称</th>
<th class="py-3 px-4">层级</th>
<th class="py-3 px-4">状态</th>
<th class="py-3 px-4">资产数量</th>
<th class="py-3 px-4 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="department in displayedDepartments"
:key="department.id"
class="border-b border-system-gray-6 text-system-label-primary text-apple-body"
>
<td class="py-3 px-4">
<div class="flex items-center gap-2" :style="{ paddingLeft: `${department.level * 20}px` }">
<div v-else-if="displayedDepartments.length === 0" class="empty-state">
<p class="text-sm text-text-secondary">没有匹配的部门。</p>
<button class="btn-secondary mt-4" @click="handleResetFilters">恢复默认视图</button>
</div>
<div v-else class="space-y-3">
<article
v-for="department in displayedDepartments"
:key="department.id"
class="panel"
:class="{ 'opacity-75': isContextDepartment(department.id) }"
>
<div class="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0 flex-1" :style="getIndentStyle(department.level)">
<div class="flex items-start gap-3">
<button
v-if="department.children_count > 0"
class="flex h-5 w-5 items-center justify-center rounded text-system-label-secondary hover:bg-system-gray-6 hover:text-system-label-primary"
type="button"
class="btn-icon shrink-0 mt-0.5"
@click="toggleExpand(department.id)"
>
{{ expandedIds.has(department.id) ? '▾' : '▸' }}
<ChevronRightIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-90': expandedIds.has(department.id) }"
/>
</button>
<span v-else class="inline-block h-5 w-5"></span>
<span v-if="department.children_count > 0" class="text-apple-blue">
{{ expandedIds.has(department.id) ? '📂' : '📁' }}
</span>
<span v-else class="text-system-label-tertiary">📄</span>
<div>
<div class="font-medium">{{ department.name }}</div>
<div class="text-apple-footnote text-system-label-tertiary">
<span v-if="department.parent_name">上级:{{ department.parent_name }} · </span>
更新于 {{ formatDateTime(department.updated_at) }}
<div
v-else
class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border bg-bg-elevated text-text-tertiary"
>
<BuildingOffice2Icon class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="truncate text-sm font-semibold text-text-primary">{{ department.name }}</h4>
<span
class="badge"
:class="department.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ department.status === 'active' ? '启用' : '停用' }}
</span>
<span class="badge badge-neutral">{{ getLevelLabel(department.level) }}</span>
<span
class="badge"
:class="department.is_synced ? 'badge-success' : 'badge-neutral'"
>
{{ department.is_synced ? '已同步' : '本地补录' }}
</span>
<span v-if="department.in_use" class="badge badge-warning">在用</span>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-2 text-xs text-text-tertiary">
<span>上级:{{ department.parent_name || '一级部门' }}</span>
<span>子部门:{{ department.children_count }}</span>
<span>使用人:{{ department.person_count ?? 0 }}</span>
<span>直属资产:{{ department.equipment_count }}</span>
<span v-if="department.children_count > 0">累计资产:{{ department.total_equipment_count }}</span>
<span>更新:{{ formatDateTime(department.updated_at) }}</span>
</div>
</div>
</div>
</td>
<td class="py-3 px-4">
<span class="rounded-apple px-2 py-1 text-apple-footnote bg-apple-blue/15 text-apple-blue">
{{ getLevelLabel(department.level) }}
</span>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="department.status === 'active' ? 'bg-apple-green/15 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ department.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td class="py-3 px-4 text-apple-body">
{{ department.equipment_count ?? 0 }}
<span v-if="department.children_count > 0" class="text-system-label-tertiary">
(含下级: {{ getTotalEquipmentCount(department) }})
</span>
</td>
<td class="py-3 px-4 text-right space-x-3">
<button class="text-apple-blue hover:underline" @click="openEdit(department)">
</div>
<div class="table-action-group lg:justify-end">
<button type="button" class="table-action table-action--primary" @click="openEdit(department)">
编辑
</button>
<button
class="text-apple-orange hover:underline"
@click="toggleStatus(department)"
>
<button type="button" class="table-action table-action--warning" @click="toggleStatus(department)">
{{ department.status === 'active' ? '停用' : '启用' }}
</button>
<button
class="text-apple-red hover:underline"
:disabled="department.children_count > 0 || department.equipment_count > 0"
:class="{ 'opacity-50 cursor-not-allowed': department.children_count > 0 || department.equipment_count > 0 }"
type="button"
class="table-action table-action--danger disabled:cursor-not-allowed disabled:opacity-45"
:disabled="isDeleteDisabled(department)"
@click="handleDelete(department)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!loading && displayedDepartments.length === 0" class="mt-6 text-center text-system-label-tertiary">
暂无使用部门数据。
</p>
</div>
</div>
</article>
</div>
</section>
<!-- 新增表单 -->
<section class="card h-fit xl:sticky xl:top-24">
<h3 class="section-title mb-4">新增使用部门</h3>
<div class="field-stack">
<label class="field-label">部门名称 *</label>
<input
v-model.trim="createForm.name"
class="input"
type="text"
placeholder="例如:市场部、技术部"
/>
</div>
<div class="field-stack mt-4">
<label class="field-label">上级部门</label>
<select v-model="createForm.parent_id" class="select">
<option :value="null">无,作为一级部门</option>
<option v-for="dept in parentOptions" :key="dept.id" :value="dept.id">
{{ dept.optionLabel }}
</option>
</select>
</div>
<button
class="btn-primary w-full mt-4"
:disabled="createLoading || !canCreate"
@click="handleCreate"
>
{{ createLoading ? '提交中...' : '新增部门' }}
</button>
</section>
</section>
<div
v-if="editingDepartment"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4"
>
<div class="w-full max-w-xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">编辑使用部门</h3>
<!-- 编辑弹窗 -->
<div v-if="editingDepartment" 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>
<label class="block text-apple-body text-system-label-primary mb-2">部门名称 *</label>
<input v-model.trim="editDraft.name" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">部门名称 *</label>
<input v-model.trim="editDraft.name" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">上级部门</label>
<div class="field-stack">
<label class="field-label">上级部门</label>
<select
v-model="editDraft.parent_id"
class="input-apple"
class="select"
:disabled="editDraft.hasChildren"
>
<option :value="null">无(作为一级部门)</option>
<option :value="null">无,作为一级部门</option>
<option
v-for="dept in parentOptions.filter((item) => item.id !== editDraft.id)"
v-for="dept in editParentOptions"
:key="dept.id"
:value="dept.id"
>
{{ dept.optionLabel }}
</option>
</select>
<p v-if="editDraft.hasChildren" class="text-apple-footnote text-apple-orange mt-1">
该部门下有子部门,暂不允许直接改挂到其他父级
<p v-if="editDraft.hasChildren" class="text-xs text-status-warning">
该部门下还有子部门,暂不允许直接改挂到其他父级。
</p>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="editDraft.status" class="input-apple">
<div class="field-stack">
<label class="field-label">状态</label>
<select v-model="editDraft.status" class="select">
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button class="btn-apple-secondary" @click="closeEdit">取消</button>
<button class="btn-apple-primary" :disabled="updateLoading" @click="submitEdit">
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeEdit">取消</button>
<button class="btn-primary" :disabled="updateLoading" @click="submitEdit">
{{ updateLoading ? '保存中...' : '保存修改' }}
</button>
</div>
@ -219,6 +266,11 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
BuildingOffice2Icon,
ChevronRightIcon
} from '@heroicons/vue/24/outline'
import { formatDateTime } from '@/utils/format'
import {
getDepartmentTree,
@ -228,30 +280,52 @@ import {
} from '@/api/department'
import type { DepartmentTreeNode } from '@/types'
type FocusFilter = 'all' | 'inUse' | 'branch' | 'leaf' | 'synced' | 'local'
type FlatDepartmentNode = DepartmentTreeNode & {
level: number
parent_name: string | null
ancestor_ids: number[]
optionLabel: string
total_equipment_count: number
in_use: boolean
is_leaf: boolean
is_synced: boolean
}
const router = useRouter()
const loading = ref(false)
const createLoading = ref(false)
const updateLoading = ref(false)
const expandedIds = ref<Set<number>>(new Set())
const treeData = ref<DepartmentTreeNode[]>([])
const editingDepartment = ref<FlatDepartmentNode | null>(null)
const collator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base'
})
const focusLabelMap: Record<FocusFilter, string> = {
all: '全部部门',
inUse: '仅在用',
branch: '仅有下级',
leaf: '仅叶子部门',
synced: '仅已同步',
local: '仅本地补录'
}
const filters = reactive({
keyword: '',
status: 'active'
status: 'active' as '' | 'active' | 'inactive',
focus: 'all' as FocusFilter
})
const treeData = ref<DepartmentTreeNode[]>([])
const createForm = reactive({
name: '',
parent_id: null as number | null
})
const editingDepartment = ref<FlatDepartmentNode | null>(null)
const editDraft = reactive({
id: 0,
name: '',
@ -260,124 +334,229 @@ const editDraft = reactive({
hasChildren: false
})
const compareDepartmentNodes = (a: DepartmentTreeNode, b: DepartmentTreeNode) => {
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1
}
const aHasChildren = a.children_count > 0
const bHasChildren = b.children_count > 0
if (aHasChildren !== bHasChildren) {
return aHasChildren ? -1 : 1
}
const aInUse = (a.person_count ?? 0) > 0 || a.equipment_count > 0
const bInUse = (b.person_count ?? 0) > 0 || b.equipment_count > 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.person_count ?? 0) !== (b.person_count ?? 0)) {
return (b.person_count ?? 0) - (a.person_count ?? 0)
}
if (a.equipment_count !== b.equipment_count) {
return b.equipment_count - a.equipment_count
}
return collator.compare(a.name, b.name)
}
const getTotalEquipmentCount = (node: DepartmentTreeNode): number =>
node.equipment_count + (node.children || []).reduce((sum, child) => sum + getTotalEquipmentCount(child), 0)
const sortDepartmentTree = (nodes: DepartmentTreeNode[]): DepartmentTreeNode[] =>
[...nodes]
.sort(compareDepartmentNodes)
.map((node) => ({
...node,
children: sortDepartmentTree(node.children || [])
}))
const flattenDepartments = (
nodes: DepartmentTreeNode[],
level = 0,
parentName: string | null = null
parentName: string | null = null,
ancestorIds: number[] = []
): FlatDepartmentNode[] => {
const list: FlatDepartmentNode[] = []
for (const node of nodes) {
list.push({
const totalEquipmentCount = getTotalEquipmentCount(node)
const item: FlatDepartmentNode = {
...node,
level,
parent_name: parentName,
optionLabel: `${level > 0 ? `${'— '.repeat(level)}` : ''}${node.name}`
})
ancestor_ids: ancestorIds,
optionLabel: `${level > 0 ? `${'— '.repeat(level)}` : ''}${node.name}`,
total_equipment_count: totalEquipmentCount,
in_use: (node.person_count ?? 0) > 0 || totalEquipmentCount > 0,
is_leaf: node.children_count === 0,
is_synced: Boolean(node.source_system)
}
list.push(item)
if (node.children?.length) {
list.push(...flattenDepartments(node.children, level + 1, node.name))
list.push(...flattenDepartments(node.children, level + 1, node.name, [...ancestorIds, node.id]))
}
}
return list
}
const flatDepartments = computed(() => flattenDepartments(treeData.value))
const parentOptions = computed(() =>
flatDepartments.value.filter((item) => item.status === 'active')
)
const displayedDepartments = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
const visibleIds = new Set<number>()
const matchedIds = new Set<number>()
const expanded = expandedIds.value
const walk = (
nodes: DepartmentTreeNode[],
level = 0,
parentName: string | null = null,
parentVisible = true
): FlatDepartmentNode[] => {
const list: FlatDepartmentNode[] = []
for (const node of nodes) {
const item: FlatDepartmentNode = {
...node,
level,
parent_name: parentName,
optionLabel: `${level > 0 ? `${'— '.repeat(level)}` : ''}${node.name}`
}
const matchKeyword =
!keyword ||
item.name.toLowerCase().includes(keyword) ||
(item.parent_name || '').toLowerCase().includes(keyword)
const matchStatus = !filters.status || item.status === filters.status
const selfMatch = matchKeyword && matchStatus
const childList = walk(node.children || [], level + 1, node.name, parentVisible && expanded.has(node.id))
const childMatched = childList.some((child) => matchedIds.has(child.id))
if (selfMatch || childMatched) {
matchedIds.add(node.id)
}
const shouldShow = keyword
? selfMatch || childMatched
: parentVisible && matchStatus
if (shouldShow) {
visibleIds.add(node.id)
list.push(item)
}
if (keyword) {
list.push(...childList.filter((child) => matchedIds.has(child.id)))
} else if (expanded.has(node.id)) {
list.push(...childList.filter((child) => visibleIds.has(child.id)))
}
}
return list
}
return walk(treeData.value)
})
const canCreate = computed(() => createForm.name.trim().length > 0)
const getTotalEquipmentCount = (dept: FlatDepartmentNode): number => {
const findNode = (nodes: DepartmentTreeNode[]): DepartmentTreeNode | null => {
for (const node of nodes) {
if (node.id === dept.id) return node
if (node.children?.length) {
const matched = findNode(node.children)
if (matched) return matched
}
}
return null
}
const calc = (node: DepartmentTreeNode): number =>
(node.equipment_count || 0) + (node.children || []).reduce((sum, child) => sum + calc(child), 0)
const target = findNode(treeData.value)
return target ? calc(target) : dept.equipment_count || 0
}
const getLevelLabel = (level: number) => `第 ${level + 1} 级`
const collectExpandableIds = (nodes: DepartmentTreeNode[]): number[] => {
const collectAllNodeIds = (nodes: DepartmentTreeNode[]): number[] => {
const ids: number[] = []
for (const node of nodes) {
if (node.children_count > 0) {
ids.push(node.id)
}
ids.push(node.id)
if (node.children?.length) {
ids.push(...collectExpandableIds(node.children))
ids.push(...collectAllNodeIds(node.children))
}
}
return ids
}
const collectRootExpandableIds = (nodes: DepartmentTreeNode[]) =>
nodes.filter((node) => node.children_count > 0).map((node) => node.id)
const sortedTreeData = computed(() => sortDepartmentTree(treeData.value))
const flatAllDepartments = computed(() => flattenDepartments(sortedTreeData.value))
const parentOptions = computed(() =>
flatAllDepartments.value.filter((item) => item.status === 'active')
)
const editParentOptions = computed(() =>
parentOptions.value.filter((item) => item.id !== editDraft.id)
)
const stats = computed(() => {
const total = flatAllDepartments.value.length
const active = flatAllDepartments.value.filter((item) => item.status === 'active').length
const synced = flatAllDepartments.value.filter((item) => item.is_synced).length
const inUse = flatAllDepartments.value.filter((item) => item.in_use).length
return {
total,
active,
inactive: total - active,
inUse,
synced,
local: total - synced
}
})
const canCreate = computed(() => createForm.name.trim().length > 0)
const focusLabel = computed(() => focusLabelMap[filters.focus])
const expandableIds = computed(() =>
flatAllDepartments.value.filter((item) => item.children_count > 0).map((item) => item.id)
)
const hasCollapsedNodes = computed(() =>
expandableIds.value.some((id) => !expandedIds.value.has(id))
)
const shouldShowContext = computed(() =>
Boolean(filters.keyword.trim()) || filters.focus !== 'all'
)
const matchesKeyword = (item: FlatDepartmentNode) => {
const keyword = filters.keyword.trim().toLowerCase()
if (!keyword) return true
return (
item.name.toLowerCase().includes(keyword) ||
(item.parent_name || '').toLowerCase().includes(keyword)
)
}
const matchesFocus = (item: FlatDepartmentNode) => {
switch (filters.focus) {
case 'inUse':
return item.in_use
case 'branch':
return item.children_count > 0
case 'leaf':
return item.is_leaf
case 'synced':
return item.is_synced
case 'local':
return !item.is_synced
default:
return true
}
}
const matchedIds = computed(() => {
const ids = new Set<number>()
for (const item of flatAllDepartments.value) {
const matchStatus = !filters.status || item.status === filters.status
if (matchStatus && matchesFocus(item) && matchesKeyword(item)) {
ids.add(item.id)
}
}
return ids
})
const contextIds = computed(() => {
if (!shouldShowContext.value) return new Set<number>()
const ids = new Set<number>()
for (const item of flatAllDepartments.value) {
if (!matchedIds.value.has(item.id)) continue
ids.add(item.id)
item.ancestor_ids.forEach((ancestorId) => ids.add(ancestorId))
}
return ids
})
const displayedDepartments = computed(() => {
if (shouldShowContext.value) {
return flatAllDepartments.value.filter((item) => contextIds.value.has(item.id))
}
return flatAllDepartments.value.filter((item) => {
if (!matchedIds.value.has(item.id)) return false
return item.ancestor_ids.every((ancestorId) => expandedIds.value.has(ancestorId))
})
})
const syncExpandedIdsWithTree = () => {
const validIds = new Set(collectAllNodeIds(treeData.value))
const nextExpanded = new Set(
[...expandedIds.value].filter((id) => validIds.has(id))
)
if (nextExpanded.size === 0) {
collectRootExpandableIds(treeData.value).forEach((id) => nextExpanded.add(id))
}
expandedIds.value = nextExpanded
}
const getLevelLabel = (level: number) => `第 ${level + 1} 级`
const getIndentStyle = (level: number) => {
const indent = Math.min(level, 4) * 18
if (!indent) return undefined
return {
paddingLeft: `${indent}px`,
borderLeft: '1px solid rgba(255, 255, 255, 0.08)'
}
}
const isContextDepartment = (id: number) =>
shouldShowContext.value && !matchedIds.value.has(id)
const isDeleteDisabled = (department: FlatDepartmentNode) =>
department.children_count > 0 ||
department.equipment_count > 0 ||
(department.person_count ?? 0) > 0
const toggleExpand = (id: number) => {
const next = new Set(expandedIds.value)
if (next.has(id)) {
@ -389,13 +568,31 @@ const toggleExpand = (id: number) => {
}
const expandAll = () => {
expandedIds.value = new Set(collectExpandableIds(treeData.value))
expandedIds.value = new Set(expandableIds.value)
}
const collapseAll = () => {
expandedIds.value = new Set()
}
const toggleExpandAll = () => {
if (hasCollapsedNodes.value) {
expandAll()
} else {
collapseAll()
}
}
const handleResetFilters = () => {
filters.keyword = ''
filters.status = 'active'
filters.focus = 'all'
}
const goToSync = () => {
router.push({ path: '/settings', query: { tab: 'sync' } })
}
const resetCreateForm = () => {
createForm.name = ''
createForm.parent_id = null
@ -405,9 +602,8 @@ const fetchDepartments = async () => {
loading.value = true
try {
const response = await getDepartmentTree()
if (response.data) {
treeData.value = response.data
}
treeData.value = response.data || []
syncExpandedIdsWithTree()
} catch (error) {
console.error('获取使用部门数据失败:', error)
} finally {
@ -417,6 +613,7 @@ const fetchDepartments = async () => {
const handleCreate = async () => {
if (!canCreate.value) return
createLoading.value = true
try {
await createDepartment({
@ -447,6 +644,7 @@ const closeEdit = () => {
const submitEdit = async () => {
if (!editingDepartment.value) return
updateLoading.value = true
try {
await updateDepartment(editDraft.id, {
@ -478,11 +676,19 @@ const handleDelete = async (department: FlatDepartmentNode) => {
alert('该部门下存在子部门,请先删除子部门')
return
}
if (department.equipment_count > 0) {
alert('该部门下存在资产,无法删除')
return
}
if ((department.person_count ?? 0) > 0) {
alert('该部门下存在人员主数据,无法删除')
return
}
if (!confirm(`确定要删除使用部门"${department.name}"吗?`)) return
try {
await deleteDepartment(department.id)
await fetchDepartments()

View File

@ -1,114 +1,261 @@
<template>
<div class="card-apple p-6 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-apple-headline text-system-label-primary">报废记录</h2>
<div class="flex items-center space-x-3">
<select v-model="filters.status" class="input-apple text-sm" @change="loadList">
<option value="">全部状态</option>
<option value="pending">待审批</option>
<option value="approved">已审批</option>
<option value="rejected">已驳回</option>
<option value="completed">已完成</option>
</select>
<input
v-model="filters.keyword"
type="text"
placeholder="搜索..."
class="input-apple text-sm w-48"
@input="handleSearch"
/>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Disposal</p>
<h2 class="page-title">报废管理</h2>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5">
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">资产编码</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">资产名称</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">报废原因</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">处置方式</th>
<th class="text-right py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">残值估价</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">状态</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">申请人</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">申请时间</th>
<th class="text-center py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id" class="border-b border-system-gray-6 hover:bg-system-gray-6/50">
<td class="py-3 px-4 text-apple-body text-system-label-primary">{{ item.equipment_code }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-primary">{{ item.equipment?.equipment_name }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary max-w-xs truncate">{{ item.disposal_reason }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary">{{ item.disposal_method }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-primary text-right">¥{{ item.estimated_value }}</td>
<td class="py-3 px-4">
<span class="px-2 py-1 text-xs rounded-full" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary">{{ item.applier?.name }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary">{{ formatDate(item.applied_at) }}</td>
<td class="py-3 px-4 text-center">
<div class="flex items-center justify-center space-x-2">
<button
v-if="item.status === 'pending'"
class="text-system-green hover:underline text-sm"
@click="handleApprove(item, true)"
>
通过
</button>
<button
v-if="item.status === 'pending'"
class="text-system-red hover:underline text-sm"
@click="handleApprove(item, false)"
>
驳回
</button>
<button
v-if="item.status === 'approved'"
class="text-system-blue hover:underline text-sm"
@click="handleComplete(item)"
>
完成处置
</button>
</div>
</td>
</tr>
<tr v-if="list.length === 0">
<td colspan="9" class="py-8 text-center text-system-label-tertiary">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="flex items-center justify-between">
<span class="text-sm text-system-label-secondary">共 {{ total }} 条记录</span>
<div class="flex items-center space-x-2">
<button class="btn-apple-secondary text-sm" :disabled="page <= 1" @click="page--; loadList()">上一页</button>
<span class="text-sm text-system-label-secondary">{{ page }} / {{ Math.ceil(total / pageSize) || 1 }}</span>
<button class="btn-apple-secondary text-sm" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadList()">下一页</button>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">待审批</p>
<p class="page-meta__value">{{ pageSummary.pending }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已审批</p>
<p class="page-meta__value">{{ pageSummary.approved }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已完成</p>
<p class="page-meta__value">{{ pageSummary.completed }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已驳回</p>
<p class="page-meta__value">{{ pageSummary.rejected }}</p>
</div>
</div>
</div>
</section>
<!-- 完成处置弹窗 -->
<div v-if="showCompleteModal" class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div class="bg-system-background-primary rounded-apple-lg p-6 w-96 shadow-apple-lg">
<h3 class="text-apple-headline text-system-label-primary mb-4">完成处置</h3>
<div class="space-y-4">
<div>
<label class="block text-sm text-system-label-secondary mb-1">实际处置价值</label>
<input v-model.number="completeForm.actual_value" type="number" class="input-apple w-full" />
<!-- 筛选区域 -->
<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.status" class="select" @change="handleQuery">
<option value="">全部状态</option>
<option value="pending">待审批</option>
<option value="approved">已审批</option>
<option value="rejected">已驳回</option>
<option value="completed">已完成</option>
</select>
</div>
<div>
<label class="block text-sm text-system-label-secondary mb-1">备注</label>
<textarea v-model="completeForm.notes" class="input-apple w-full" rows="3"></textarea>
<div class="field-stack">
<label class="field-label">搜索记录</label>
<input
v-model.trim="filters.keyword"
type="text"
class="input"
placeholder="输入资产编码、名称、报废原因或处置方式"
@input="handleSearch"
@keyup.enter="handleQuery"
/>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button class="btn-apple-secondary" @click="showCompleteModal = false">取消</button>
<button class="btn-apple-primary" @click="submitComplete">确认完成</button>
<div class="filter-bar__actions">
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="resetFilters">重置</button>
<button class="btn-primary text-xs sm:text-sm" :disabled="loading" @click="handleQuery">
{{ loading ? '查询中...' : '查询' }}
</button>
</div>
</div>
</section>
<!-- 数据列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">报废记录</h3>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="loadList">刷新</button>
</div>
<!-- 移动端卡片 -->
<div class="mobile-card-list md:hidden">
<article v-for="item in list" :key="`mobile-${item.id}`" class="mobile-card">
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">{{ item.equipment?.equipment_name || '-' }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
</div>
<span class="badge" :class="getStatusBadgeClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</div>
<div class="mobile-card__meta">
<div>
<p class="mobile-card__label">申请人</p>
<p class="mobile-card__value">{{ item.applier?.name || '-' }}</p>
</div>
<div>
<p class="mobile-card__label">申请时间</p>
<p class="mobile-card__value">{{ formatDate(item.applied_at) }}</p>
</div>
<div>
<p class="mobile-card__label">处置方式</p>
<p class="mobile-card__value">{{ item.disposal_method || '未填写' }}</p>
</div>
<div>
<p class="mobile-card__label">残值估价</p>
<p class="mobile-card__value">{{ formatCurrency(item.estimated_value) }}</p>
</div>
</div>
<div class="mt-4 rounded-lg border border-border bg-bg-elevated px-4 py-3">
<p class="mobile-card__label">报废原因</p>
<p class="mobile-card__value">{{ item.disposal_reason || '未填写' }}</p>
</div>
<div v-if="item.notes" class="mt-3 rounded-lg border border-border bg-bg-elevated px-4 py-3">
<p class="mobile-card__label">备注</p>
<p class="mobile-card__value">{{ item.notes }}</p>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button
v-if="item.status === 'pending'"
class="btn-primary text-xs sm:text-sm"
@click="handleApprove(item, true)"
>
通过
</button>
<button
v-if="item.status === 'pending'"
class="btn-secondary text-xs sm:text-sm !text-status-error"
@click="handleApprove(item, false)"
>
驳回
</button>
<button
v-if="item.status === 'approved'"
class="btn-secondary text-xs sm:text-sm"
@click="handleComplete(item)"
>
完成
</button>
</div>
</article>
<p v-if="!loading && list.length === 0" class="text-center text-sm text-text-tertiary py-8">
当前筛选条件下暂无报废记录。
</p>
</div>
<!-- 桌面端表格 -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table min-w-[1080px]">
<thead>
<tr>
<th>资产编码</th>
<th>资产名称</th>
<th>报废原因</th>
<th>处置方式</th>
<th class="text-right">残值估价</th>
<th>状态</th>
<th>申请人</th>
<th>申请时间</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id">
<td class="font-mono text-sm text-text-secondary">{{ item.equipment_code }}</td>
<td>
<p class="font-medium text-text-primary">{{ item.equipment?.equipment_name || '-' }}</p>
<p class="text-xs text-text-tertiary">{{ item.equipment?.equipment_type || '-' }}</p>
</td>
<td class="max-w-[260px]">
<p class="truncate text-sm text-text-secondary">{{ item.disposal_reason }}</p>
</td>
<td class="text-sm text-text-secondary">{{ item.disposal_method || '-' }}</td>
<td class="text-right text-sm text-text-primary">{{ formatCurrency(item.estimated_value) }}</td>
<td>
<span class="badge" :class="getStatusBadgeClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</td>
<td class="text-sm text-text-secondary">{{ item.applier?.name || '-' }}</td>
<td class="text-sm text-text-secondary">{{ formatDate(item.applied_at) }}</td>
<td class="text-right">
<div class="table-action-group justify-end">
<button
v-if="item.status === 'pending'"
class="table-action table-action--primary"
@click="handleApprove(item, true)"
>
通过
</button>
<button
v-if="item.status === 'pending'"
class="table-action table-action--danger"
@click="handleApprove(item, false)"
>
驳回
</button>
<button
v-if="item.status === 'approved'"
class="table-action table-action--warning"
@click="handleComplete(item)"
>
完成
</button>
<span v-if="item.status === 'completed'" class="text-sm text-text-tertiary">已完成</span>
<span v-if="item.status === 'rejected'" class="text-sm text-text-tertiary">已驳回</span>
</div>
</td>
</tr>
<tr v-if="!loading && list.length === 0">
<td colspan="9" class="py-8 text-center text-text-tertiary">当前筛选条件下暂无报废记录。</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="pagination-bar">
<p class="text-sm text-text-secondary">第 {{ page }} / {{ totalPages }} 页</p>
<div class="flex items-center gap-2">
<button class="btn-secondary text-sm" :disabled="page <= 1 || loading" @click="handlePrevPage">上一页</button>
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
</div>
</div>
</section>
<!-- 完成处置弹窗 -->
<div v-if="showCompleteModal" class="sheet-backdrop">
<div class="sheet-panel max-w-2xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">完成处置</h3>
<p class="text-sm text-text-secondary mb-4">
{{ currentItem?.equipment?.equipment_name || currentItem?.equipment_code || '当前资产' }} 已通过审批,请补充最终处置结果。
</p>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">实际处置价值</label>
<input v-model.number="completeForm.actual_value" type="number" min="0" step="0.01" class="input" />
</div>
<div class="panel">
<p class="text-xs text-text-tertiary">残值估价</p>
<p class="mt-1 text-base font-medium text-text-primary">{{ formatCurrency(currentItem?.estimated_value || 0) }}</p>
</div>
</div>
<div class="mt-4 field-stack">
<label class="field-label">备注</label>
<textarea
v-model.trim="completeForm.notes"
class="input"
rows="4"
placeholder="例如:已转交回收商,回收凭证已登记"
></textarea>
</div>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="showCompleteModal = false">取消</button>
<button class="btn-primary" :disabled="submittingComplete" @click="submitComplete">
{{ submittingComplete ? '提交中...' : '确认完成' }}
</button>
</div>
</div>
</div>
@ -116,27 +263,40 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { getDisposalList, approveDisposal, completeDisposal, type DisposalRecord } from '@/api/disposal'
const list = ref<DisposalRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const filters = ref({ status: '', keyword: '' })
const showCompleteModal = ref(false)
const currentItem = ref<DisposalRecord | null>(null)
const completeForm = ref({ actual_value: 0, notes: '' })
const submittingComplete = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pageSummary = computed(() => ({
pending: list.value.filter((item) => item.status === 'pending').length,
approved: list.value.filter((item) => item.status === 'approved').length,
completed: list.value.filter((item) => item.status === 'completed').length,
rejected: list.value.filter((item) => item.status === 'rejected').length
}))
const loadList = async () => {
loading.value = true
try {
const res = await getDisposalList({ page: page.value, pageSize: pageSize.value, ...filters.value })
list.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
@ -148,12 +308,24 @@ const handleSearch = () => {
}, 300)
}
const handleQuery = () => {
page.value = 1
loadList()
}
const resetFilters = () => {
filters.value.status = ''
filters.value.keyword = ''
handleQuery()
}
const handleApprove = async (item: DisposalRecord, approved: boolean) => {
const msg = approved ? '确定通过此报废申请吗?' : '确定驳回此报废申请吗?'
if (!confirm(msg)) return
try {
await approveDisposal(item.id, { approved })
loadList()
const rejectReason = approved ? undefined : window.prompt('可填写驳回原因,方便后续重新提交时参考。', '') || undefined
await approveDisposal(item.id, { approved, reject_reason: rejectReason })
await loadList()
} catch (e) {
console.error(e)
}
@ -161,33 +333,53 @@ const handleApprove = async (item: DisposalRecord, approved: boolean) => {
const handleComplete = (item: DisposalRecord) => {
currentItem.value = item
completeForm.value = { actual_value: item.estimated_value, notes: '' }
completeForm.value = { actual_value: Number(item.estimated_value || 0), notes: item.notes || '' }
showCompleteModal.value = true
}
const submitComplete = async () => {
if (!currentItem.value) return
try {
submittingComplete.value = true
await completeDisposal(currentItem.value.id, completeForm.value)
showCompleteModal.value = false
loadList()
await loadList()
} catch (e) {
console.error(e)
} finally {
submittingComplete.value = false
}
}
const getStatusClass = (status: string) => {
const handlePrevPage = async () => {
if (page.value <= 1) return
page.value -= 1
await loadList()
}
const handleNextPage = async () => {
if (page.value >= totalPages.value) return
page.value += 1
await loadList()
}
const getStatusBadgeClass = (status: string) => {
const map: Record<string, string> = {
'pending': 'bg-system-yellow/10 text-system-yellow',
'approved': 'bg-system-blue/10 text-system-blue',
'rejected': 'bg-system-red/10 text-system-red',
'completed': 'bg-system-green/10 text-system-green'
pending: 'badge-warning',
approved: 'badge-neutral',
rejected: 'badge-error',
completed: 'badge-success'
}
return map[status] || ''
}
const getStatusText = (status: string) => {
const map: Record<string, string> = { 'pending': '待审批', 'approved': '已审批', 'rejected': '已驳回', 'completed': '已完成' }
const map: Record<string, string> = {
pending: '待审批',
approved: '已审批',
rejected: '已驳回',
completed: '已完成'
}
return map[status] || status
}
@ -196,5 +388,7 @@ const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const formatCurrency = (value: number) => `¥${Number(value || 0).toFixed(2)}`
onMounted(loadList)
</script>

View File

@ -0,0 +1,893 @@
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">HR Master Data</p>
<h2 class="page-title">组织人员同步</h2>
</div>
<div class="flex flex-wrap gap-2 sm:gap-3">
<button
class="btn-secondary text-xs sm:text-sm"
:disabled="statusLoading || previewLoading || syncLoading"
@click="refreshStatusAndPreview"
>
{{ statusLoading || previewLoading ? '读取中...' : '刷新' }}
</button>
<button
class="btn-primary text-xs sm:text-sm"
:disabled="statusLoading || previewLoading || syncLoading || !syncReady"
@click="handleRunSync"
>
{{ syncLoading ? '同步中...' : '同步' }}
</button>
</div>
</section>
<!-- 统计卡片 -->
<section class="grid gap-4 lg:grid-cols-2">
<div class="card">
<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>
</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"
>
正在检查同步配置...
</div>
<div
v-else-if="!syncReady"
class="mt-5 rounded-lg border border-status-warning/30 bg-status-warning/10 px-4 py-5"
>
<p class="text-sm font-medium text-text-primary">同步源库还没有配置完成</p>
<p class="mt-2 text-sm 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"
>
<p class="text-sm font-medium text-text-primary">读取预览失败</p>
<p class="mt-2 text-sm text-text-secondary">{{ previewErrorMessage }}</p>
</div>
<div v-else-if="previewData" class="mt-5 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>
</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>
</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>
</div>
<div class="card">
<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>
</div>
<span v-if="lastRunAt" class="text-sm text-text-tertiary">完成于 {{ lastRunAt }}</span>
</div>
<div class="mt-5 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>
<p class="mt-2 text-sm 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>
<p class="mt-2 text-sm text-text-secondary">
{{ syncReady ? '可以先刷新预览,再执行正式同步。' : '当前先不会执行同步,避免误报错。' }}
</p>
</div>
</div>
<div v-if="runResult" class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="panel">
<p class="text-sm font-medium text-text-primary">部门结果</p>
<p class="mt-3 text-sm text-text-secondary">新增 {{ runResult.departments.created }} 个</p>
<p class="mt-1 text-sm text-text-secondary">更新 {{ runResult.departments.updated }} 个</p>
<p class="mt-1 text-sm text-text-secondary">停用 {{ runResult.departments.deactivated }} 个</p>
</div>
<div class="panel">
<p class="text-sm font-medium text-text-primary">人员结果</p>
<p class="mt-3 text-sm text-text-secondary">新增 {{ runResult.persons.created }} 人</p>
<p class="mt-1 text-sm text-text-secondary">更新 {{ runResult.persons.updated }} 人</p>
<p class="mt-1 text-sm text-text-secondary">停用 {{ runResult.persons.deactivated }} 人</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>
</div>
</section>
<!-- 样本数据 -->
<section v-if="previewData" class="card">
<div class="grid gap-6 lg:grid-cols-2">
<div>
<div class="flex items-center justify-between gap-3">
<h3 class="section-title">部门样本</h3>
<span class="text-sm text-text-tertiary">展示前 {{ previewData.departments.sample.length }} 条</span>
</div>
<div class="mt-4 space-y-3">
<div
v-for="department in previewData.departments.sample"
:key="department.sourceGroupId"
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>
</div>
<p class="mt-2 text-sm text-text-secondary">
上级源ID:{{ department.sourceParentGroupId ?? '顶级部门' }}
</p>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-3">
<h3 class="section-title">人员样本</h3>
<span class="text-sm text-text-tertiary">展示前 {{ previewData.persons.sample.length }} 条</span>
</div>
<div class="mt-4 space-y-3">
<div
v-for="person in previewData.persons.sample"
:key="person.sourceUserId"
class="panel"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-text-primary">{{ person.nickname }} / {{ person.realName }}</p>
<p class="mt-1 text-sm text-text-secondary">
{{ person.departmentName || '未回填部门名称' }}
<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>
<p class="mt-2 text-xs text-text-tertiary">
主部门源ID:{{ person.sourceMainDepartmentId ?? '-' }},部门列表:{{ person.sourceDeptIds || '-' }}
</p>
</div>
</div>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<div>
<h3 class="section-title">同步历史</h3>
<p class="mt-1 text-sm text-text-tertiary">点击左侧任务可查看本次同步的变化明细。</p>
</div>
<span class="badge badge-neutral">共 {{ historyTotal }} 次</span>
</div>
<div class="mt-5 grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
<div class="space-y-3">
<div
v-if="historyLoading && historyList.length === 0"
class="panel text-sm text-text-tertiary"
>
正在读取同步历史...
</div>
<button
v-for="job in historyList"
:key="job.id"
type="button"
class="panel w-full text-left transition"
:class="selectedJobId === job.id ? 'border-accent-primary/40 bg-accent-primary/5' : 'hover:border-border-hover hover:bg-bg-hover/40'"
@click="handleSelectJob(job.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">{{ formatDateTime(job.created_at) }}</p>
<p class="mt-1 text-xs text-text-secondary">
{{ getTriggerModeLabel(job.trigger_mode) }} · {{ job.operator || 'system-sync' }}
</p>
</div>
<span class="badge" :class="getJobStatusBadgeClass(job.status)">
{{ getJobStatusLabel(job.status) }}
</span>
</div>
<p class="mt-3 line-clamp-3 text-sm leading-6 text-text-secondary">
{{ job.summary || '本次同步没有额外摘要。' }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
<span class="badge badge-neutral">变化 {{ job.changes_count }}</span>
<span
v-for="pair in summaryPairs(job).slice(0, 3)"
:key="`${job.id}-${pair.label}`"
class="badge badge-neutral"
>
{{ pair.label }} {{ pair.count }}
</span>
</div>
</button>
<div v-if="!historyLoading && historyList.length === 0" class="empty-state">
还没有同步历史记录。
</div>
<div v-if="historyTotal > 0" class="pagination-bar">
<p class="text-sm text-text-tertiary">
第 {{ historyPage }} / {{ historyTotalPages }} 页
</p>
<div class="flex gap-2">
<button
class="btn-secondary text-sm"
:disabled="historyPage === 1 || historyLoading"
@click="handlePrevHistoryPage"
>
上一页
</button>
<button
class="btn-secondary text-sm"
:disabled="historyPage >= historyTotalPages || historyLoading"
@click="handleNextHistoryPage"
>
下一页
</button>
</div>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<h4 class="section-title">任务详情</h4>
<span v-if="selectedJob" class="text-sm text-text-tertiary">
{{ formatDateTime(selectedJob.created_at) }}
</span>
</div>
<div v-if="detailLoading" class="panel text-sm text-text-tertiary">
正在读取任务详情...
</div>
<div v-else-if="selectedJob" class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<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">
{{ getJobStatusLabel(selectedJob.status) }}
</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">
{{ getTriggerModeLabel(selectedJob.trigger_mode) }}
</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">
{{ selectedJob.operator || 'system-sync' }}
</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">
{{ selectedJob.changes_count }}
</p>
</div>
</div>
<div class="panel">
<p class="text-sm font-medium text-text-primary">同步摘要</p>
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ selectedJob.summary || '本次同步没有额外摘要。' }}
</p>
<p v-if="selectedJob.error_message" class="mt-3 text-sm text-status-error">
错误信息:{{ selectedJob.error_message }}
</p>
</div>
<div v-if="summaryPairs(selectedJob).length > 0" class="flex flex-wrap gap-2">
<span
v-for="pair in summaryPairs(selectedJob)"
:key="`detail-${pair.label}`"
class="badge badge-neutral"
>
{{ pair.label }} {{ pair.count }}
</span>
</div>
<div class="panel">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">变化明细</p>
<span class="badge badge-neutral">{{ selectedJob.changes.length }} 条</span>
</div>
<div v-if="selectedJob.changes.length > 0" class="mt-3 space-y-3">
<div class="flex flex-wrap gap-2">
<span class="badge badge-warning">待处理 {{ changeProcessingSummary.pending }}</span>
<span class="badge badge-success">已处理 {{ changeProcessingSummary.processed }}</span>
<span class="badge badge-neutral">已忽略 {{ changeProcessingSummary.ignored }}</span>
</div>
<div
v-for="change in selectedJob.changes"
:key="change.id"
class="rounded-lg border border-border bg-bg-secondary px-4 py-3"
>
<div class="flex items-start justify-between gap-3">
<p class="text-sm font-medium text-text-primary">{{ change.title }}</p>
<span class="badge badge-neutral">{{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }}</span>
</div>
<p class="mt-2 text-sm leading-6 text-text-secondary">
{{ change.detail || '无详细说明' }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-tertiary">
<span class="badge" :class="getChangeProcessingBadgeClass(change.processing_status)">
{{ getChangeProcessingLabel(change.processing_status) }}
</span>
<span>处理人:{{ change.processed_by_name || change.processed_by || '未处理' }}</span>
<span>处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}</span>
</div>
<div class="mt-4 rounded-lg border border-border bg-bg-elevated/50 px-4 py-4">
<div class="field-grid lg:grid-cols-[220px_minmax(0,1fr)]">
<div class="field-stack">
<label class="text-sm font-medium text-text-secondary">处理状态</label>
<select
v-model="getChangeDraft(change).processing_status"
class="select"
:disabled="Boolean(changeSubmittingMap[change.id])"
@change="clearChangeNotice(change.id)"
>
<option value="pending">待处理</option>
<option value="processed">已处理</option>
<option value="ignored">已忽略</option>
</select>
</div>
<div class="field-stack">
<label class="text-sm font-medium text-text-secondary">处理备注</label>
<textarea
v-model="getChangeDraft(change).processing_notes"
class="input min-h-[92px]"
rows="3"
placeholder="补充这条变更的处理结果,方便行政回查。"
:disabled="Boolean(changeSubmittingMap[change.id])"
@input="clearChangeNotice(change.id)"
></textarea>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<p
class="text-xs"
:class="isChangeDraftDirty(change) ? 'text-text-secondary' : 'text-text-tertiary'"
>
{{ isChangeDraftDirty(change) ? '这条变更有未保存修改' : '当前内容已和服务器同步' }}
</p>
<button
type="button"
class="btn-primary text-sm"
:disabled="Boolean(changeSubmittingMap[change.id]) || !isChangeDraftDirty(change)"
@click="handleSubmitChangeProcessing(change)"
>
{{ changeSubmittingMap[change.id] ? '提交中...' : '保存处理结果' }}
</button>
</div>
<p
v-if="changeNoticeMap[change.id]"
class="mt-3 text-sm"
:class="changeNoticeMap[change.id]?.type === 'error' ? 'text-status-error' : 'text-status-success'"
>
{{ changeNoticeMap[change.id]?.message }}
</p>
</div>
</div>
</div>
<div v-else class="empty-state mt-3">本次同步没有变化明细。</div>
</div>
</div>
<div v-else class="empty-state">
请选择左侧一条同步任务查看详情。
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type {
HrSyncChangeItem,
HrSyncChangeProcessingStatus,
HrSyncJobDetail,
HrSyncJobHistoryItem,
HrSyncPreview,
HrSyncResult,
HrSyncStatus,
UpdateHrSyncChangeProcessingPayload
} from '@/types'
import {
getHrSyncJobDetail,
getHrSyncJobs,
getHrSyncStatus,
previewHrSync,
runHrSync,
updateHrSyncChangeProcessing
} from '@/api/sync'
import { formatDateTime } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const previewLoading = ref(false)
const statusLoading = ref(false)
const syncLoading = ref(false)
const historyLoading = ref(false)
const detailLoading = ref(false)
const previewData = ref<HrSyncPreview | null>(null)
const runResult = ref<HrSyncResult | null>(null)
const syncStatus = ref<HrSyncStatus | null>(null)
const previewErrorMessage = ref('')
const lastRunAt = ref('')
const historyList = ref<HrSyncJobHistoryItem[]>([])
const historyTotal = ref(0)
const historyPage = ref(1)
const historyPageSize = 8
const selectedJobId = ref<number | null>(null)
const selectedJob = ref<HrSyncJobDetail | null>(null)
const changeDraftMap = reactive<Record<number, { processing_status: HrSyncChangeProcessingStatus; processing_notes: string }>>({})
const changeSubmittingMap = reactive<Record<number, boolean>>({})
const changeNoticeMap = reactive<Record<number, { type: 'success' | 'error'; message: string } | null>>({})
const fieldLabelMap: Record<string, string> = {
SQLSERVER_HOST: '源库地址',
SQLSERVER_DATABASE: '源库库名',
SQLSERVER_USER: '登录账号',
SQLSERVER_PASSWORD: '登录密码'
}
const CHANGE_TYPE_LABELS: Record<string, string> = {
person_created: '新人入职',
person_deactivated: '人员离职/停用',
person_reactivated: '重新启用',
person_main_department_changed: '主部门变动',
person_departments_changed: '关联部门变动',
person_position_changed: '岗位变动',
department_created: '新增部门',
department_renamed: '部门名称变动',
department_hierarchy_changed: '部门层级变动',
department_deactivated: '部门停用',
department_reactivated: '部门重新启用'
}
const CHANGE_PROCESSING_LABELS: Record<HrSyncChangeProcessingStatus, string> = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
const CHANGE_PROCESSING_BADGES: Record<HrSyncChangeProcessingStatus, string> = {
pending: 'badge-warning',
processed: 'badge-success',
ignored: 'badge-neutral'
}
const departmentCoverage = computed(() => {
if (!previewData.value || previewData.value.departments.raw_total === 0) {
return 0
}
return Math.round((previewData.value.departments.total / previewData.value.departments.raw_total) * 100)
})
const syncReady = computed(() => Boolean(syncStatus.value?.complete))
const missingFieldLabels = computed(() =>
(syncStatus.value?.missingFields || []).map((field) => fieldLabelMap[field] || field)
)
const configurationMessage = computed(() => {
if (missingFieldLabels.value.length === 0) {
return '同步源库还没有配置完成。'
}
return `当前缺少:${missingFieldLabels.value.join('、')}。补齐后再点"刷新预览"即可。`
})
const statusBadgeText = computed(() => {
if (statusLoading.value) return '检查中'
return syncReady.value ? '可同步' : '待配置'
})
const statusBadgeClass = computed(() => {
if (statusLoading.value) return 'badge-neutral'
return syncReady.value ? 'badge-success' : 'badge-warning'
})
const scheduleSummary = computed(() => {
const scheduleTimes = syncStatus.value?.scheduleTimes || []
if (!syncStatus.value?.enabled) {
return '建议先确认源库连通后,再决定是否开启自动同步。'
}
return scheduleTimes.length > 0
? `计划时间:${scheduleTimes.join('、')}`
: '已启用定时计划,但还没有读取到具体时间。'
})
const historyTotalPages = computed(() => Math.max(1, Math.ceil(historyTotal.value / historyPageSize)))
const changeProcessingSummary = computed(() => {
const summary = {
pending: 0,
processed: 0,
ignored: 0
}
for (const change of selectedJob.value?.changes || []) {
summary[normalizeChangeProcessingStatus(change.processing_status)] += 1
}
return summary
})
const resolveErrorMessage = (error: any, fallback: string) =>
error?.response?.data?.message || error?.message || fallback
const normalizeChangeProcessingStatus = (
status?: HrSyncChangeProcessingStatus | string | null
): HrSyncChangeProcessingStatus => {
if (status === 'processed' || status === 'ignored') {
return status
}
return 'pending'
}
const resolveRouteSyncJobId = () => {
const rawValue = Array.isArray(route.query.syncJobId) ? route.query.syncJobId[0] : route.query.syncJobId
const parsed = Number(rawValue)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
const summaryPairs = (job: Pick<HrSyncJobHistoryItem, 'counts'>) =>
Object.entries(job.counts || {})
.filter(([, count]) => Number(count) > 0)
.map(([key, count]) => ({
label: CHANGE_TYPE_LABELS[key] || key,
count: Number(count)
}))
const getJobStatusLabel = (status?: string) => (status === 'failed' ? '失败' : '成功')
const getJobStatusBadgeClass = (status?: string) => (status === 'failed' ? 'badge-error' : 'badge-success')
const getTriggerModeLabel = (mode?: string) => (mode === 'scheduler' ? '定时执行' : '手动执行')
const getChangeProcessingLabel = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_LABELS[normalizeChangeProcessingStatus(status)]
const getChangeProcessingBadgeClass = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_BADGES[normalizeChangeProcessingStatus(status)]
const syncChangeDrafts = (changes: HrSyncChangeItem[]) => {
const validIds = new Set(changes.map((change) => change.id))
Object.keys(changeDraftMap).forEach((key) => {
const changeId = Number(key)
if (!validIds.has(changeId)) {
delete changeDraftMap[changeId]
delete changeSubmittingMap[changeId]
delete changeNoticeMap[changeId]
}
})
changes.forEach((change) => {
changeDraftMap[change.id] = {
processing_status: normalizeChangeProcessingStatus(change.processing_status),
processing_notes: change.processing_notes || ''
}
if (typeof changeSubmittingMap[change.id] !== 'boolean') {
changeSubmittingMap[change.id] = false
}
if (!(change.id in changeNoticeMap)) {
changeNoticeMap[change.id] = null
}
})
}
const getChangeDraft = (change: HrSyncChangeItem) => {
if (!changeDraftMap[change.id]) {
changeDraftMap[change.id] = {
processing_status: normalizeChangeProcessingStatus(change.processing_status),
processing_notes: change.processing_notes || ''
}
}
return changeDraftMap[change.id]
}
const isChangeDraftDirty = (change: HrSyncChangeItem) => {
const draft = getChangeDraft(change)
return (
draft.processing_status !== normalizeChangeProcessingStatus(change.processing_status) ||
draft.processing_notes !== (change.processing_notes || '')
)
}
const clearChangeNotice = (changeId: number) => {
changeNoticeMap[changeId] = null
}
const resetChangeStates = () => {
Object.keys(changeDraftMap).forEach((key) => delete changeDraftMap[Number(key)])
Object.keys(changeSubmittingMap).forEach((key) => delete changeSubmittingMap[Number(key)])
Object.keys(changeNoticeMap).forEach((key) => delete changeNoticeMap[Number(key)])
}
const mergeUpdatedChange = (updatedChange: Partial<HrSyncChangeItem> & { id: number }, fallbackDraft?: {
processing_status: HrSyncChangeProcessingStatus
processing_notes: string
}) => {
if (!selectedJob.value) return
selectedJob.value = {
...selectedJob.value,
changes: selectedJob.value.changes.map((change) => {
if (change.id !== updatedChange.id) {
return change
}
return {
...change,
...updatedChange,
processing_status: normalizeChangeProcessingStatus(
updatedChange.processing_status ?? fallbackDraft?.processing_status ?? change.processing_status
),
processing_notes: updatedChange.processing_notes ?? fallbackDraft?.processing_notes ?? change.processing_notes
}
})
}
syncChangeDrafts(selectedJob.value.changes)
}
const fetchStatus = async () => {
statusLoading.value = true
try {
const response = await getHrSyncStatus()
syncStatus.value = response.data
} catch (error: any) {
syncStatus.value = {
enabled: false,
complete: false,
missingFields: [],
scheduleTimes: []
}
previewErrorMessage.value = resolveErrorMessage(error, '读取同步状态失败')
} finally {
statusLoading.value = false
}
}
const fetchPreview = async () => {
if (!syncReady.value) {
previewData.value = null
previewErrorMessage.value = configurationMessage.value
return
}
previewLoading.value = true
previewErrorMessage.value = ''
try {
const response = await previewHrSync()
previewData.value = response.data
} catch (error: any) {
previewData.value = null
previewErrorMessage.value = resolveErrorMessage(error, '读取同步预览失败')
} finally {
previewLoading.value = false
}
}
const refreshStatusAndPreview = async () => {
await fetchStatus()
await fetchPreview()
}
const fetchHistory = async (page = historyPage.value) => {
historyLoading.value = true
try {
const response = await getHrSyncJobs({
page,
pageSize: historyPageSize
})
if (response.data) {
historyList.value = response.data.list
historyTotal.value = response.data.total
historyPage.value = response.data.page
}
} catch (error: any) {
alert(resolveErrorMessage(error, '读取同步历史失败'))
} finally {
historyLoading.value = false
}
}
const fetchJobDetail = async (jobId: number) => {
detailLoading.value = true
try {
const response = await getHrSyncJobDetail(jobId)
if (response.data) {
selectedJob.value = response.data
selectedJobId.value = response.data.id
syncChangeDrafts(response.data.changes)
}
} catch (error: any) {
selectedJob.value = null
selectedJobId.value = null
resetChangeStates()
alert(resolveErrorMessage(error, '读取同步任务详情失败'))
} finally {
detailLoading.value = false
}
}
const handleSubmitChangeProcessing = async (change: HrSyncChangeItem) => {
const draft = getChangeDraft(change)
const payload: UpdateHrSyncChangeProcessingPayload = {
processing_status: draft.processing_status,
processing_notes: draft.processing_notes.trim() || null
}
changeSubmittingMap[change.id] = true
changeNoticeMap[change.id] = null
try {
const response = await updateHrSyncChangeProcessing(change.id, payload)
mergeUpdatedChange(
{
...(response.data || { id: change.id }),
processing_status: response.data?.processing_status ?? payload.processing_status,
processing_notes: response.data?.processing_notes ?? payload.processing_notes
},
draft
)
changeNoticeMap[change.id] = {
type: 'success',
message: '处理结果已保存'
}
} catch (error: any) {
changeNoticeMap[change.id] = {
type: 'error',
message: resolveErrorMessage(error, '保存处理结果失败')
}
} finally {
changeSubmittingMap[change.id] = false
}
}
const handleSelectJob = async (jobId: number) => {
await router.replace({
query: {
...route.query,
tab: 'sync',
syncJobId: String(jobId)
}
})
}
const handlePrevHistoryPage = async () => {
if (historyPage.value <= 1) return
await fetchHistory(historyPage.value - 1)
}
const handleNextHistoryPage = async () => {
if (historyPage.value >= historyTotalPages.value) return
await fetchHistory(historyPage.value + 1)
}
const handleRunSync = async () => {
if (!syncReady.value) {
alert(configurationMessage.value)
return
}
syncLoading.value = true
try {
const response = await runHrSync()
if (response.data) {
runResult.value = response.data
lastRunAt.value = formatDateTime(new Date().toISOString())
await Promise.all([fetchPreview(), fetchHistory(1)])
if (response.data.sync_job_id) {
await handleSelectJob(response.data.sync_job_id)
}
alert(
`同步完成:部门新增 ${response.data.departments.created} 个、更新 ${response.data.departments.updated} 个;` +
`人员新增 ${response.data.persons.created} 人、更新 ${response.data.persons.updated} 人。`
)
}
} catch (error: any) {
alert(resolveErrorMessage(error, '执行同步失败'))
} finally {
syncLoading.value = false
}
}
watch(
() => route.query.syncJobId,
async () => {
const routeJobId = resolveRouteSyncJobId()
if (!routeJobId) {
selectedJobId.value = null
selectedJob.value = null
resetChangeStates()
return
}
if (selectedJob.value?.id === routeJobId) {
selectedJobId.value = routeJobId
return
}
await fetchJobDetail(routeJobId)
}
)
onMounted(async () => {
await Promise.all([refreshStatusAndPreview(), fetchHistory()])
const routeJobId = resolveRouteSyncJobId()
if (routeJobId) {
await fetchJobDetail(routeJobId)
return
}
if (historyList.value.length > 0) {
await handleSelectJob(historyList.value[0].id)
}
})
</script>

View File

@ -1,116 +1,229 @@
<template>
<div class="card-apple p-6 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-apple-headline text-system-label-primary">维修记录</h2>
<div class="flex items-center space-x-3">
<select v-model="filters.status" class="input-apple text-sm" @change="loadList">
<option value="">全部状态</option>
<option value="in_progress">维修中</option>
<option value="completed">已完成</option>
<option value="cancelled">已取消</option>
</select>
<input
v-model="filters.keyword"
type="text"
placeholder="搜索..."
class="input-apple text-sm w-48"
@input="handleSearch"
/>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Maintenance</p>
<h2 class="page-title">维修管理</h2>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5">
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">资产编码</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">资产名称</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">故障描述</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">维修商</th>
<th class="text-right py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">预估费用</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">状态</th>
<th class="text-left py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">送修日期</th>
<th class="text-center py-3 px-4 text-apple-footnote text-system-label-secondary font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id" class="border-b border-system-gray-6 hover:bg-system-gray-6/50">
<td class="py-3 px-4 text-apple-body text-system-label-primary">{{ item.equipment_code }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-primary">{{ item.equipment?.equipment_name }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary max-w-xs truncate">{{ item.issue_description }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary">{{ item.repair_vendor || '-' }}</td>
<td class="py-3 px-4 text-apple-body text-system-label-primary text-right">¥{{ item.estimated_cost }}</td>
<td class="py-3 px-4">
<span class="px-2 py-1 text-xs rounded-full" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</td>
<td class="py-3 px-4 text-apple-body text-system-label-secondary">{{ formatDate(item.start_date) }}</td>
<td class="py-3 px-4 text-center">
<div class="flex items-center justify-center space-x-2">
<button
v-if="item.status === 'in_progress'"
class="text-system-green hover:underline text-sm"
@click="handleComplete(item)"
>
完成
</button>
<button
v-if="item.status === 'in_progress'"
class="text-system-red hover:underline text-sm"
@click="handleCancel(item)"
>
取消
</button>
</div>
</td>
</tr>
<tr v-if="list.length === 0">
<td colspan="8" class="py-8 text-center text-system-label-tertiary">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="flex items-center justify-between">
<span class="text-sm text-system-label-secondary">共 {{ total }} 条记录</span>
<div class="flex items-center space-x-2">
<button
class="btn-apple-secondary text-sm"
:disabled="page <= 1"
@click="page--; loadList()"
>
上一页
</button>
<span class="text-sm text-system-label-secondary">{{ page }} / {{ Math.ceil(total / pageSize) || 1 }}</span>
<button
class="btn-apple-secondary text-sm"
:disabled="page >= Math.ceil(total / pageSize)"
@click="page++; loadList()"
>
下一页
</button>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">维修中</p>
<p class="page-meta__value">{{ pageSummary.inProgress }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已完成</p>
<p class="page-meta__value">{{ pageSummary.completed }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">已取消</p>
<p class="page-meta__value">{{ pageSummary.cancelled }}</p>
</div>
</div>
</div>
</section>
<!-- 完成维修弹窗 -->
<div v-if="showCompleteModal" class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div class="bg-system-background-primary rounded-apple-lg p-6 w-96 shadow-apple-lg">
<h3 class="text-apple-headline text-system-label-primary mb-4">完成维修</h3>
<div class="space-y-4">
<div>
<label class="block text-sm text-system-label-secondary mb-1">实际费用</label>
<input v-model.number="completeForm.actual_cost" type="number" class="input-apple w-full" />
<!-- 筛选区域 -->
<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.status" class="select" @change="handleQuery">
<option value="">全部状态</option>
<option value="in_progress">维修中</option>
<option value="completed">已完成</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div>
<label class="block text-sm text-system-label-secondary mb-1">维修结果</label>
<textarea v-model="completeForm.repair_result" class="input-apple w-full" rows="3"></textarea>
<div class="field-stack">
<label class="field-label">搜索记录</label>
<input
v-model.trim="filters.keyword"
type="text"
class="input"
placeholder="输入资产编码、名称、故障描述或维修商"
@input="handleSearch"
@keyup.enter="handleQuery"
/>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button class="btn-apple-secondary" @click="showCompleteModal = false">取消</button>
<button class="btn-apple-primary" @click="submitComplete">确认完成</button>
<div class="filter-bar__actions">
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="resetFilters">重置</button>
<button class="btn-primary text-xs sm:text-sm" :disabled="loading" @click="handleQuery">
{{ loading ? '查询中...' : '查询' }}
</button>
</div>
</div>
</section>
<!-- 数据列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">维修记录</h3>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="loadList">刷新</button>
</div>
<!-- 移动端卡片 -->
<div class="mobile-card-list md:hidden">
<article v-for="item in list" :key="`mobile-${item.id}`" class="mobile-card">
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">{{ item.equipment?.equipment_name || '-' }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
</div>
<span class="badge" :class="getStatusBadgeClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</div>
<div class="mobile-card__meta">
<div>
<p class="mobile-card__label">维修商</p>
<p class="mobile-card__value">{{ item.repair_vendor || '未填写' }}</p>
</div>
<div>
<p class="mobile-card__label">送修日期</p>
<p class="mobile-card__value">{{ formatDate(item.start_date) }}</p>
</div>
<div>
<p class="mobile-card__label">预估费用</p>
<p class="mobile-card__value">{{ formatCurrency(item.estimated_cost) }}</p>
</div>
<div>
<p class="mobile-card__label">完成日期</p>
<p class="mobile-card__value">{{ formatDate(item.actual_end_date || '') }}</p>
</div>
</div>
<div class="mt-4 rounded-lg border border-border bg-bg-elevated px-4 py-3">
<p class="mobile-card__label">故障描述</p>
<p class="mobile-card__value">{{ item.issue_description || '未填写' }}</p>
</div>
<div v-if="item.repair_result" class="mt-3 rounded-lg border border-border bg-bg-elevated px-4 py-3">
<p class="mobile-card__label">维修结果</p>
<p class="mobile-card__value">{{ item.repair_result }}</p>
</div>
<div v-if="item.status === 'in_progress'" class="mt-4 flex flex-wrap gap-2">
<button class="btn-primary text-xs sm:text-sm" @click="handleComplete(item)">完成</button>
<button class="btn-secondary text-xs sm:text-sm !text-status-error" @click="handleCancel(item)">取消</button>
</div>
</article>
<p v-if="!loading && list.length === 0" class="text-center text-sm text-text-tertiary py-8">
当前筛选条件下暂无维修记录。
</p>
</div>
<!-- 桌面端表格 -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table min-w-[980px]">
<thead>
<tr>
<th>资产编码</th>
<th>资产名称</th>
<th>故障描述</th>
<th>维修商</th>
<th class="text-right">预估费用</th>
<th>状态</th>
<th>送修日期</th>
<th>完成日期</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id">
<td class="font-mono text-sm text-text-secondary">{{ item.equipment_code }}</td>
<td>
<p class="font-medium text-text-primary">{{ item.equipment?.equipment_name || '-' }}</p>
<p class="text-xs text-text-tertiary">{{ item.equipment?.equipment_type || '-' }}</p>
</td>
<td class="max-w-[260px]">
<p class="truncate text-sm text-text-secondary">{{ item.issue_description }}</p>
</td>
<td class="text-sm text-text-secondary">{{ item.repair_vendor || '-' }}</td>
<td class="text-right text-sm text-text-primary">{{ formatCurrency(item.estimated_cost) }}</td>
<td>
<span class="badge" :class="getStatusBadgeClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</td>
<td class="text-sm text-text-secondary">{{ formatDate(item.start_date) }}</td>
<td class="text-sm text-text-secondary">{{ formatDate(item.actual_end_date || '') }}</td>
<td class="text-right">
<div class="table-action-group justify-end">
<button
v-if="item.status === 'in_progress'"
class="table-action table-action--primary"
@click="handleComplete(item)"
>
完成
</button>
<button
v-if="item.status === 'in_progress'"
class="table-action table-action--danger"
@click="handleCancel(item)"
>
取消
</button>
<span v-if="item.status !== 'in_progress'" class="text-sm text-text-tertiary">已结束</span>
</div>
</td>
</tr>
<tr v-if="!loading && list.length === 0">
<td colspan="9" class="py-8 text-center text-text-tertiary">当前筛选条件下暂无维修记录。</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="pagination-bar">
<p class="text-sm text-text-secondary">第 {{ page }} / {{ totalPages }} 页</p>
<div class="flex items-center gap-2">
<button class="btn-secondary text-sm" :disabled="page <= 1 || loading" @click="handlePrevPage">上一页</button>
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
</div>
</div>
</section>
<!-- 完成维修弹窗 -->
<div v-if="showCompleteModal" class="sheet-backdrop">
<div class="sheet-panel max-w-2xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">完成维修</h3>
<p class="text-sm text-text-secondary mb-4">
{{ currentItem?.equipment?.equipment_name || currentItem?.equipment_code || '当前资产' }} 的维修即将结束,请补充费用和维修结果。
</p>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">实际费用</label>
<input v-model.number="completeForm.actual_cost" type="number" min="0" step="0.01" class="input" />
</div>
<div class="panel">
<p class="text-xs text-text-tertiary">预估费用</p>
<p class="mt-1 text-base font-medium text-text-primary">{{ formatCurrency(currentItem?.estimated_cost || 0) }}</p>
</div>
</div>
<div class="mt-4 field-stack">
<label class="field-label">维修结果</label>
<textarea
v-model.trim="completeForm.repair_result"
class="input"
rows="4"
placeholder="例如:已更换电源模块,功能恢复正常"
></textarea>
</div>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="showCompleteModal = false">取消</button>
<button class="btn-primary" :disabled="submittingComplete" @click="submitComplete">
{{ submittingComplete ? '提交中...' : '确认完成' }}
</button>
</div>
</div>
</div>
@ -118,27 +231,39 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { getMaintenanceList, completeMaintenance, cancelMaintenance, type MaintenanceRecord } from '@/api/maintenance'
const list = ref<MaintenanceRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const filters = ref({ status: '', keyword: '' })
const showCompleteModal = ref(false)
const currentItem = ref<MaintenanceRecord | null>(null)
const completeForm = ref({ actual_cost: 0, repair_result: '' })
const submittingComplete = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pageSummary = computed(() => ({
inProgress: list.value.filter((item) => item.status === 'in_progress').length,
completed: list.value.filter((item) => item.status === 'completed').length,
cancelled: list.value.filter((item) => item.status === 'cancelled').length
}))
const loadList = async () => {
loading.value = true
try {
const res = await getMaintenanceList({ page: page.value, pageSize: pageSize.value, ...filters.value })
list.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
@ -150,44 +275,71 @@ const handleSearch = () => {
}, 300)
}
const handleQuery = () => {
page.value = 1
loadList()
}
const resetFilters = () => {
filters.value.status = ''
filters.value.keyword = ''
handleQuery()
}
const handleComplete = (item: MaintenanceRecord) => {
currentItem.value = item
completeForm.value = { actual_cost: item.estimated_cost, repair_result: '' }
completeForm.value = { actual_cost: Number(item.estimated_cost || 0), repair_result: item.repair_result || '' }
showCompleteModal.value = true
}
const submitComplete = async () => {
if (!currentItem.value) return
try {
submittingComplete.value = true
await completeMaintenance(currentItem.value.id, completeForm.value)
showCompleteModal.value = false
loadList()
await loadList()
} catch (e) {
console.error(e)
} finally {
submittingComplete.value = false
}
}
const handleCancel = async (item: MaintenanceRecord) => {
if (!confirm('确定要取消此维修记录吗?')) return
try {
await cancelMaintenance(item.id, { reason: '手动取消' })
loadList()
const reason = window.prompt('如需备注取消原因,可以在这里填写。', '手动取消') || '手动取消'
await cancelMaintenance(item.id, { reason })
await loadList()
} catch (e) {
console.error(e)
}
}
const getStatusClass = (status: string) => {
const handlePrevPage = async () => {
if (page.value <= 1) return
page.value -= 1
await loadList()
}
const handleNextPage = async () => {
if (page.value >= totalPages.value) return
page.value += 1
await loadList()
}
const getStatusBadgeClass = (status: string) => {
const map: Record<string, string> = {
'in_progress': 'bg-system-blue/10 text-system-blue',
'completed': 'bg-system-green/10 text-system-green',
'cancelled': 'bg-system-gray-5 text-system-label-secondary'
in_progress: 'badge-warning',
completed: 'badge-success',
cancelled: 'badge-neutral'
}
return map[status] || ''
}
const getStatusText = (status: string) => {
const map: Record<string, string> = { 'in_progress': '维修中', 'completed': '已完成', 'cancelled': '已取消' }
const map: Record<string, string> = { in_progress: '维修中', completed: '已完成', cancelled: '已取消' }
return map[status] || status
}
@ -196,5 +348,7 @@ const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const formatCurrency = (value: number) => `¥${Number(value || 0).toFixed(2)}`
onMounted(loadList)
</script>

View File

@ -1,87 +1,149 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="flex-1 grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">操作类型</label>
<select v-model="filters.operationType" class="input-apple" @change="handleSearch">
<option value="">全部类型</option>
<option v-for="type in operationTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">操作人</label>
<input v-model.trim="filters.username" type="text" class="input-apple" placeholder="输入用户名" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">开始日期</label>
<input v-model="filters.startDate" type="date" class="input-apple" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">结束日期</label>
<input v-model="filters.endDate" type="date" class="input-apple" />
</div>
<!-- 页面头部 -->
<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>
<div class="flex space-x-3">
<button class="btn-apple-secondary" @click="handleReset">重置</button>
<button class="btn-apple-primary" :disabled="loading" @click="handleSearch">查询</button>
<div class="page-meta">
<p class="page-meta__label">借还流转</p>
<p class="page-meta__value">{{ logSummary.borrowOps }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">风险处置</p>
<p class="page-meta__value">{{ logSummary.riskOps }}</p>
</div>
</div>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<h2 class="text-apple-title">操作日志</h2>
<span class="text-apple-footnote text-system-label-tertiary">共 {{ total }} 条记录</span>
<!-- 筛选区域 -->
<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>
<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="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>
<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">
{{ loading ? '查询中...' : '查询' }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">操作时间</th>
<th class="py-3 px-4">操作人</th>
<th class="py-3 px-4">操作类型</th>
<th class="py-3 px-4">资产编码</th>
<th class="py-3 px-4">操作详情</th>
<th class="py-3 px-4">IP地址</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id" class="border-b border-system-gray-6 text-apple-body text-system-label-primary">
<td class="py-3 px-4 text-apple-footnote text-system-label-secondary whitespace-nowrap">
{{ formatDateTime(log.created_at) }}
</td>
<td class="py-3 px-4">{{ log.user?.name || log.username || '-' }}</td>
<td class="py-3 px-4">
<span class="px-2 py-1 rounded-apple text-apple-footnote" :class="getTypeClass(log.operation_type)">
{{ log.operation_type }}
</span>
</td>
<td class="py-3 px-4 font-mono text-apple-footnote">{{ log.equipment_code || '-' }}</td>
<td class="py-3 px-4 text-apple-footnote text-system-label-secondary max-w-xs truncate">
{{ log.operation_details || '-' }}
</td>
<td class="py-3 px-4 font-mono text-apple-footnote text-system-label-tertiary">
{{ log.ip_address || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!loading && logs.length === 0" class="mt-6 text-center text-system-label-tertiary">暂无操作日志。</p>
</section>
<div v-if="totalPages > 1" class="flex items-center justify-center mt-4 pt-4 border-t border-system-gray-5 space-x-2">
<button @click="handlePrevPage" :disabled="currentPage === 1" class="btn-apple-secondary">上一页</button>
<span class="text-apple-body">{{ currentPage }} / {{ totalPages }}</span>
<button @click="handleNextPage" :disabled="currentPage === totalPages" class="btn-apple-secondary">下一页</button>
<!-- 数据列表 -->
<section class="card">
<div class="list-header">
<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">
<div>
<p class="text-sm font-semibold text-text-primary">{{ log.user?.name || log.username || '系统操作' }}</p>
<p class="mt-1 text-xs text-text-tertiary">{{ formatDateTime(log.created_at) }}</p>
</div>
<span class="badge" :class="getTypeBadgeClass(log.operation_type)">{{ log.operation_type }}</span>
</div>
<div class="mobile-card__meta">
<div>
<p class="mobile-card__label">资产编码</p>
<p class="mobile-card__value font-mono">{{ log.equipment_code || '-' }}</p>
</div>
<div>
<p class="mobile-card__label">IP 地址</p>
<p class="mobile-card__value font-mono">{{ log.ip_address || '-' }}</p>
</div>
</div>
<div class="mt-4 rounded-lg border border-border bg-bg-elevated px-4 py-3">
<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>
</div>
<!-- 桌面端表格 -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table min-w-[1040px]">
<thead>
<tr>
<th>操作时间</th>
<th>操作人</th>
<th>操作类型</th>
<th>资产编码</th>
<th>操作详情</th>
<th>IP 地址</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td class="whitespace-nowrap text-sm text-text-secondary">{{ formatDateTime(log.created_at) }}</td>
<td class="text-sm text-text-primary">{{ log.user?.name || log.username || '-' }}</td>
<td>
<span class="badge" :class="getTypeBadgeClass(log.operation_type)">{{ log.operation_type }}</span>
</td>
<td class="font-mono text-sm text-text-secondary">{{ log.equipment_code || '-' }}</td>
<td class="max-w-[360px]">
<p class="truncate text-sm text-text-secondary">{{ log.operation_details || '-' }}</p>
</td>
<td class="font-mono text-sm text-text-tertiary">{{ log.ip_address || '-' }}</td>
</tr>
<tr v-if="!loading && logs.length === 0">
<td colspan="6" class="py-8 text-center text-text-tertiary">当前筛选条件下暂无操作日志。</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="pagination-bar">
<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>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { getOperationLogList, getOperationTypes } from '@/api/operationLog'
interface OperationLog {
@ -111,7 +173,15 @@ const filters = reactive({
endDate: ''
})
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const logSummary = computed(() => {
const includesAny = (value: string, keys: string[]) => keys.some((key) => value.includes(key))
const assetOps = logs.value.filter((log) => includesAny(log.operation_type, ['新增', '修改', '更新', '导入', '删除'])).length
const borrowOps = logs.value.filter((log) => includesAny(log.operation_type, ['借出', '归还', '转移', '批量'])).length
const riskOps = logs.value.filter((log) => includesAny(log.operation_type, ['维修', '报废', '审批', '取消'])).length
return { assetOps, borrowOps, riskOps }
})
const fetchLogs = async () => {
loading.value = true
@ -146,7 +216,11 @@ const fetchOperationTypes = async () => {
}
}
const handleSearch = () => { currentPage.value = 1; fetchLogs() }
const handleSearch = () => {
currentPage.value = 1
fetchLogs()
}
const handleReset = () => {
filters.operationType = ''
filters.username = ''
@ -154,27 +228,40 @@ const handleReset = () => {
filters.endDate = ''
handleSearch()
}
const handlePrevPage = () => { if (currentPage.value > 1) { currentPage.value--; fetchLogs() } }
const handleNextPage = () => { if (currentPage.value < totalPages.value) { currentPage.value++; fetchLogs() } }
const handlePrevPage = () => {
if (currentPage.value > 1) {
currentPage.value -= 1
fetchLogs()
}
}
const handleNextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value += 1
fetchLogs()
}
}
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const getTypeClass = (type: string) => {
if (type.includes('删除')) return 'bg-apple-red/10 text-apple-red'
if (type.includes('报废')) return 'bg-apple-red/10 text-apple-red'
if (type.includes('新增') || type.includes('导入')) return 'bg-apple-green/10 text-apple-green'
if (type.includes('维修完成')) return 'bg-apple-green/10 text-apple-green'
if (type.includes('修改') || type.includes('更新')) return 'bg-apple-blue/10 text-apple-blue'
if (type.includes('审批')) return 'bg-apple-blue/10 text-apple-blue'
if (type.includes('借出')) return 'bg-apple-orange/10 text-apple-orange'
if (type.includes('送修') || type.includes('维护')) return 'bg-apple-orange/10 text-apple-orange'
if (type.includes('归还')) return 'bg-apple-teal/10 text-apple-teal'
if (type.includes('转移') || type.includes('批量')) return 'bg-apple-purple/10 text-apple-purple'
if (type.includes('取消')) return 'bg-system-gray-5 text-system-label-secondary'
return 'bg-system-gray-6 text-system-label-secondary'
const getTypeBadgeClass = (type: string) => {
if (type.includes('删除') || type.includes('报废')) return 'badge-error'
if (type.includes('新增') || type.includes('导入') || type.includes('维修完成')) return 'badge-success'
if (type.includes('借出') || type.includes('送修') || type.includes('维护')) return 'badge-warning'
if (type.includes('归还')) return 'badge-success'
if (type.includes('修改') || type.includes('更新') || type.includes('审批') || type.includes('转移') || type.includes('批量')) return 'badge-neutral'
if (type.includes('取消')) return 'badge-neutral'
return 'badge-neutral'
}
onMounted(() => {

View File

@ -1,214 +1,277 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="rounded-apple bg-apple-blue/10 px-4 py-3 text-apple-footnote text-system-label-secondary">
这里是资产“使用者”信息,可从钉钉人员资料同步;它不等于“用户管理”里的登录账号。
</div>
<div class="mt-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<label class="block text-apple-body text-system-label-primary mb-2">搜索使用者</label>
<input
v-model.trim="keyword"
type="text"
class="input-apple"
class="input"
placeholder="输入花名 / 姓名 / 证件编号"
@keyup.enter="handleSearch"
/>
</div>
<div class="flex flex-wrap gap-3">
<select v-model="statusFilter" class="input-apple" @change="handleSearch">
<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-apple-secondary" :disabled="listLoading" @click="handleSearch">查询</button>
<button class="btn-apple-primary" @click="showCreateModal = true">新增使用者</button>
<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>
</div>
</div>
<div class="flex flex-wrap gap-3 mt-4 pt-4 border-t border-system-gray-5">
<button class="btn-apple-secondary" @click="downloadTemplate">下载导入模板</button>
<label class="btn-apple-primary cursor-pointer">
<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="downloadTemplate">下载模板</button>
<label class="btn-primary text-xs sm:text-sm cursor-pointer">
批量导入
<input type="file" accept=".csv,.xlsx,.xls" class="hidden" @change="handleFileImport" />
</label>
</div>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4 gap-4">
<!-- 列表 -->
<section class="card">
<div class="list-header">
<div>
<h2 class="text-apple-title">使用者列表</h2>
<p class="mt-1 text-apple-footnote text-system-label-tertiary">
<h3 class="section-title">使用者列表</h3>
<p class="mt-1 text-sm text-text-tertiary">
当前共 {{ total }} 人,本页已加载 {{ persons.length }} 人
</p>
</div>
<button class="btn-apple-secondary" :disabled="listLoading" @click="fetchPersons">刷新</button>
<button class="btn-secondary" :disabled="listLoading" @click="fetchPersons">刷新</button>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">花名</th>
<th class="py-3 px-4">真实姓名</th>
<th class="py-3 px-4">证件编号</th>
<th class="py-3 px-4">部门 / 职位</th>
<th class="py-3 px-4">联系方式</th>
<th class="py-3 px-4">状态</th>
<th class="py-3 px-4 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="person in persons"
:key="person.id"
class="border-b border-system-gray-6 text-apple-body text-system-label-primary"
>
<td class="py-3 px-4">{{ person.nickname }}</td>
<td class="py-3 px-4">{{ person.real_name }}</td>
<td class="py-3 px-4 font-mono text-apple-footnote text-system-label-secondary">{{ person.id_number || '-' }}</td>
<td class="py-3 px-4 text-apple-footnote text-system-label-secondary">
<p>{{ person.department || '-' }}</p>
<p v-if="person.position">{{ person.position }}</p>
</td>
<td class="py-3 px-4 text-apple-footnote text-system-label-secondary">
<p v-if="person.phone">📞 {{ person.phone }}</p>
<p v-if="person.email">📧 {{ person.email }}</p>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="person.status === 'active' ? 'bg-apple-green/10 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ person.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="py-3 px-4 text-right space-x-2">
<button class="text-apple-blue hover:underline" @click="openEdit(person)">编辑</button>
<button class="text-apple-red hover:underline" @click="toggleStatus(person)">
{{ person.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="text-apple-red hover:underline" @click="handleDeletePerson(person)">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 移动端卡片列表 -->
<div class="mobile-card-list md:hidden">
<div v-for="person in persons" :key="person.id" class="mobile-card">
<div class="flex items-start justify-between">
<div>
<p class="font-medium text-text-primary">{{ person.nickname }}</p>
<p class="text-sm text-text-secondary">{{ person.real_name }}</p>
</div>
<div class="flex gap-1">
<span
class="badge"
:class="person.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ person.status === 'active' ? '启用' : '禁用' }}
</span>
<span
class="badge"
:class="person.is_synced ? 'badge-success' : 'badge-neutral'"
>
{{ person.is_synced ? '已同步' : '本地' }}
</span>
</div>
</div>
<div class="mt-3 text-sm text-text-secondary space-y-1">
<p>{{ person.main_department?.name || person.department || '-' }}</p>
<p v-if="person.position">{{ person.position }}</p>
<p v-if="person.phone">{{ person.phone }}</p>
<p v-if="person.email">{{ person.email }}</p>
</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)">
{{ person.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDeletePerson(person)">删除</button>
</div>
</div>
</div>
<div v-if="total > 0" class="mt-4 flex items-center justify-between border-t border-system-gray-5 pt-4">
<p class="text-apple-footnote text-system-label-tertiary">
<!-- 桌面端表格 -->
<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>
<th>同步</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="person in persons" :key="person.id">
<td class="font-medium">{{ person.nickname }}</td>
<td>{{ person.real_name }}</td>
<td class="font-mono text-sm text-text-secondary">{{ person.id_number || '-' }}</td>
<td class="text-sm text-text-secondary">
<p>{{ person.main_department?.name || person.department || '-' }}</p>
<p v-if="person.position" class="text-text-tertiary">{{ person.position }}</p>
<p
v-if="(person.department_names?.length || 0) > 1"
class="text-text-tertiary"
>
关联 {{ person.department_names?.length }} 个部门
</p>
</td>
<td class="text-sm text-text-secondary">
<p v-if="person.phone">{{ person.phone }}</p>
<p v-if="person.email">{{ person.email }}</p>
</td>
<td>
<span
class="badge"
:class="person.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ person.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="text-sm text-text-secondary">
<span
class="badge"
:class="person.is_synced ? 'badge-success' : 'badge-neutral'"
>
{{ person.is_synced ? '已同步' : '本地' }}
</span>
<p class="mt-1 text-xs text-text-tertiary">
{{ formatSyncTime(person.last_synced_at) }}
</p>
</td>
<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)">
{{ person.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDeletePerson(person)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="total > 0" class="pagination-bar">
<p class="text-sm text-text-tertiary">
第 {{ currentPage }} / {{ totalPages }} 页,每页 {{ pageSize }} 人
</p>
<div class="flex items-center gap-2">
<button class="btn-apple-secondary" :disabled="currentPage === 1 || listLoading" @click="handlePrevPage">
<button class="btn-secondary" :disabled="currentPage === 1 || listLoading" @click="handlePrevPage">
上一页
</button>
<button class="btn-apple-secondary" :disabled="currentPage >= totalPages || listLoading" @click="handleNextPage">
<button class="btn-secondary" :disabled="currentPage >= totalPages || listLoading" @click="handleNextPage">
下一页
</button>
</div>
</div>
<p v-if="!listLoading && persons.length === 0" class="mt-6 text-center text-system-label-tertiary">暂无使用者数据。</p>
<p v-if="!listLoading && persons.length === 0" class="mt-6 text-center text-text-tertiary">暂无使用者数据。</p>
</section>
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">新增使用者</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">花名 *</label>
<input v-model.trim="newPerson.nickname" class="input-apple" type="text" placeholder="花名 / 昵称" />
<!-- 新增弹窗 -->
<div v-if="showCreateModal" class="sheet-backdrop">
<div class="sheet-panel max-w-3xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">新增使用者</h3>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">花名 *</label>
<input v-model.trim="newPerson.nickname" class="input" type="text" placeholder="花名 / 昵称" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">真实姓名 *</label>
<input v-model.trim="newPerson.real_name" class="input-apple" type="text" placeholder="真实姓名" />
<div class="field-stack">
<label class="field-label">真实姓名 *</label>
<input v-model.trim="newPerson.real_name" class="input" type="text" placeholder="真实姓名" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">证件编号</label>
<input v-model.trim="newPerson.id_number" class="input-apple" type="text" placeholder="身份证 / 工号(可选)" />
<div class="field-stack">
<label class="field-label">证件编号</label>
<input v-model.trim="newPerson.id_number" class="input" type="text" placeholder="身份证 / 工号(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">部门</label>
<input v-model.trim="newPerson.department" class="input-apple" type="text" placeholder="部门(可选)" />
<div class="field-stack">
<label class="field-label">部门</label>
<input v-model.trim="newPerson.department" class="input" type="text" placeholder="部门(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">职位</label>
<input v-model.trim="newPerson.position" class="input-apple" type="text" placeholder="职位(可选)" />
<div class="field-stack">
<label class="field-label">职位</label>
<input v-model.trim="newPerson.position" class="input" type="text" placeholder="职位(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">手机号</label>
<input v-model.trim="newPerson.phone" class="input-apple" type="tel" placeholder="手机号(可选)" />
<div class="field-stack">
<label class="field-label">手机号</label>
<input v-model.trim="newPerson.phone" class="input" type="tel" placeholder="手机号(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">邮箱</label>
<input v-model.trim="newPerson.email" class="input-apple" type="email" placeholder="邮箱(可选)" />
<div class="field-stack">
<label class="field-label">邮箱</label>
<input v-model.trim="newPerson.email" class="input" type="email" placeholder="邮箱(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="newPerson.status" class="input-apple">
<div class="field-stack">
<label class="field-label">状态</label>
<select v-model="newPerson.status" class="select">
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
</div>
</div>
<div class="mt-4">
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<textarea v-model.trim="newPerson.remarks" class="input-apple" rows="3" placeholder="备注信息(可选)"></textarea>
<div class="field-stack mt-4">
<label class="field-label">备注</label>
<textarea v-model.trim="newPerson.remarks" class="input" rows="3" placeholder="备注信息(可选)"></textarea>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button class="btn-apple-secondary" @click="closeCreateModal">取消</button>
<button class="btn-apple-primary" :disabled="createLoading || !canCreate" @click="handleCreatePerson">
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeCreateModal">取消</button>
<button class="btn-primary" :disabled="createLoading || !canCreate" @click="handleCreatePerson">
{{ createLoading ? '提交中...' : '新增使用者' }}
</button>
</div>
</div>
</div>
<div v-if="editingPerson" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">编辑使用者</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">花名</label>
<input v-model.trim="editDraft.nickname" class="input-apple" type="text" />
<!-- 编辑弹窗 -->
<div v-if="editingPerson" class="sheet-backdrop">
<div class="sheet-panel max-w-3xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">编辑使用者</h3>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">花名</label>
<input v-model.trim="editDraft.nickname" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">真实姓名</label>
<input v-model.trim="editDraft.real_name" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">真实姓名</label>
<input v-model.trim="editDraft.real_name" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">证件编号</label>
<input v-model.trim="editDraft.id_number" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">证件编号</label>
<input v-model.trim="editDraft.id_number" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">部门</label>
<input v-model.trim="editDraft.department" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">部门</label>
<input v-model.trim="editDraft.department" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">职位</label>
<input v-model.trim="editDraft.position" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">职位</label>
<input v-model.trim="editDraft.position" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">手机号</label>
<input v-model.trim="editDraft.phone" class="input-apple" type="tel" />
<div class="field-stack">
<label class="field-label">手机号</label>
<input v-model.trim="editDraft.phone" class="input" type="tel" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">邮箱</label>
<input v-model.trim="editDraft.email" class="input-apple" type="email" />
<div class="field-stack">
<label class="field-label">邮箱</label>
<input v-model.trim="editDraft.email" class="input" type="email" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="editDraft.status" class="input-apple">
<div class="field-stack">
<label class="field-label">状态</label>
<select v-model="editDraft.status" class="select">
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
</div>
</div>
<div class="mt-4">
<label class="block text-apple-body text-system-label-primary mb-2">备注</label>
<textarea v-model.trim="editDraft.remarks" class="input-apple" rows="3"></textarea>
<div class="field-stack mt-4">
<label class="field-label">备注</label>
<textarea v-model.trim="editDraft.remarks" class="input" rows="3"></textarea>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button class="btn-apple-secondary" @click="closeEdit">取消</button>
<button class="btn-apple-primary" :disabled="updateLoading" @click="submitEdit">{{ updateLoading ? '保存中...' : '保存修改' }}</button>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeEdit">取消</button>
<button class="btn-primary" :disabled="updateLoading" @click="submitEdit">
{{ updateLoading ? '保存中...' : '保存修改' }}
</button>
</div>
</div>
</div>
@ -217,11 +280,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import type { Person } from '@/types'
import { createPerson, getPersonList, updatePerson, updatePersonStatus, deletePerson } from '@/api/person'
import { batchImportPersons } from '@/api/batchImport'
import { formatDateTime } from '@/utils/format'
const router = useRouter()
const persons = ref<Person[]>([])
const total = ref(0)
const currentPage = ref(1)
@ -245,6 +311,12 @@ const editDraft = reactive({
const canCreate = computed(() => newPerson.nickname.trim() && newPerson.real_name.trim())
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const formatSyncTime = (value?: string | null) => (value ? formatDateTime(value) : '未同步')
const goToSync = () => {
router.push({ path: '/settings', query: { tab: 'sync' } })
}
const handleSearch = () => {
currentPage.value = 1
fetchPersons()

View File

@ -1,45 +1,50 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<h2 class="text-apple-title mb-4">站点基础信息</h2>
<section class="card">
<h3 class="section-title mb-4">站点基础信息</h3>
<div class="space-y-4">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">网站名称 *</label>
<div class="field-stack">
<label class="field-label">网站名称 *</label>
<input
v-model.trim="form.siteName"
type="text"
class="input-apple"
class="input"
placeholder="请输入网站名称"
/>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">主题主色</label>
<input
v-model="form.themeColor"
type="color"
class="h-10 w-20 rounded-apple border border-system-gray-5 bg-transparent"
/>
</div>
<div class="panel space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-text-primary">Logo 素材目录</p>
<p class="text-sm leading-6 text-text-secondary">
默认素材跟随项目一起部署;如果需要自定义,直接把文件放到同一目录即可,系统会自动优先使用自定义文件。
</p>
</div>
<div class="rounded-apple border border-dashed border-system-gray-5 bg-system-gray-6/40 p-4">
<p class="text-apple-footnote text-system-label-tertiary">
网站 Logo 与浏览器图标已固定为系统默认标识,不支持自定义上传。
</p>
<div class="mt-4 flex items-center space-x-3">
<div class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-apple bg-white shadow-apple">
<img :src="logoUrl" alt="默认 Logo" class="h-full w-full object-cover" />
<div class="field-grid">
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">项目目录</p>
<p class="mt-2 font-mono text-sm text-text-primary">frontend/public/branding/</p>
</div>
<div class="text-apple-footnote text-system-label-secondary">
<p>默认地址:</p>
<p class="truncate">{{ logoUrl }}</p>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">服务器目录</p>
<p class="mt-2 font-mono text-sm text-text-primary">/opt/equipment-management/frontend/public/branding/</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">默认文件</p>
<p class="mt-2 text-sm text-text-primary">`logo.svg` / `favicon.svg`</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">自定义覆盖</p>
<p class="mt-2 text-sm text-text-primary">`custom-logo.*` / `custom-favicon.*`</p>
</div>
</div>
</div>
<div class="pt-4">
<div class="pt-2">
<button
class="btn-apple-primary"
class="btn-primary text-xs sm:text-sm"
:disabled="saving || !form.siteName"
@click="handleSubmit"
>
@ -49,29 +54,29 @@
</div>
</section>
<section class="card-apple">
<h3 class="text-apple-title mb-4">实时预览</h3>
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-apple border border-system-gray-5 bg-system-background-primary p-6">
<p class="text-apple-footnote text-system-label-tertiary mb-2">导航栏预览</p>
<section class="card">
<h3 class="section-title mb-4">实时预览</h3>
<div class="field-grid">
<div class="bg-bg-elevated border border-border rounded-xl p-6">
<p class="mb-2 text-xs uppercase tracking-wider text-text-tertiary">导航栏预览</p>
<div class="flex items-center space-x-3">
<div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded-apple bg-system-gray-6">
<div class="flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-border bg-bg-secondary">
<img :src="logoUrl" alt="logo" class="h-full w-full object-cover" />
</div>
<span class="text-apple-title text-system-label-primary">{{ form.siteName }}</span>
<span class="text-lg font-semibold text-text-primary">{{ form.siteName }}</span>
</div>
</div>
<div class="rounded-apple border border-system-gray-5 bg-system-background-primary p-6">
<p class="text-apple-footnote text-system-label-tertiary mb-2">登录页预览</p>
<div class="rounded-apple border border-system-gray-5 bg-white/80 p-6 shadow-apple backdrop-blur-sm">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-apple bg-apple-blue/10">
<img :src="logoUrl" alt="logo" class="h-full w-full rounded-apple object-cover" />
<div class="bg-bg-elevated border border-border rounded-xl p-6">
<p class="mb-2 text-xs uppercase tracking-wider text-text-tertiary">浏览器图标预览</p>
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-border bg-bg-secondary">
<img :src="faviconUrl" alt="favicon" class="h-full w-full object-cover" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-text-primary">{{ faviconUrl }}</p>
<p class="mt-1 text-sm text-text-secondary">浏览器标签页会自动读取这个文件。</p>
</div>
<p class="mt-4 text-center text-apple-title">{{ form.siteName }}</p>
<p class="mt-2 text-center text-apple-footnote text-system-label-tertiary">
使用者体验将同步更新。
</p>
</div>
</div>
</div>
@ -88,15 +93,14 @@ const siteStore = useSiteStore()
const saving = ref(false)
const form = ref({
siteName: siteStore.siteName,
themeColor: siteStore.themeColor
siteName: siteStore.siteName
})
const logoUrl = computed(() => siteStore.logoUrl)
const faviconUrl = computed(() => siteStore.faviconUrl)
const syncFromStore = () => {
form.value.siteName = siteStore.siteName
form.value.themeColor = siteStore.themeColor
}
onMounted(async () => {
@ -105,7 +109,7 @@ onMounted(async () => {
})
watch(
() => [siteStore.siteName, siteStore.themeColor],
() => siteStore.siteName,
() => syncFromStore()
)
@ -118,8 +122,7 @@ const handleSubmit = async () => {
try {
saving.value = true
const response = await updateSiteConfig({
site_name: form.value.siteName,
theme_color: form.value.themeColor
site_name: form.value.siteName
})
if (response.data) {
siteStore.setSiteConfig(response.data)
@ -132,4 +135,3 @@ const handleSubmit = async () => {
}
}
</script>

View File

@ -1,61 +1,62 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="grid gap-4 md:grid-cols-3 md:items-end">
<div class="md:col-span-2">
<label class="block text-apple-body text-system-label-primary mb-2">搜索地址</label>
<!-- 筛选区域 -->
<section class="card">
<div class="filter-bar">
<div class="flex-1">
<input
v-model.trim="filters.keyword"
class="input-apple"
class="input"
type="text"
placeholder="输入地址名称 / 编码 / 备注关键字"
@keyup.enter="fetchLocations"
/>
</div>
<div class="flex gap-3">
<select v-model="filters.status" class="input-apple">
<div class="flex flex-wrap gap-2 sm:gap-3">
<select v-model="filters.status" class="select">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchLocations">查询</button>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchLocations">查询</button>
</div>
</div>
</section>
<section class="card-apple">
<h2 class="text-apple-title mb-4">新增地址</h2>
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">地址名称 *</label>
<!-- 新增表单 -->
<section class="card">
<h3 class="section-title mb-4">新增地址</h3>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">地址名称 *</label>
<input
v-model.trim="createForm.name"
class="input-apple"
class="input"
type="text"
placeholder="例如:A区-01柜"
/>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">地址编码</label>
<div class="field-stack">
<label class="field-label">地址编码</label>
<input
v-model.trim="createForm.code"
class="input-apple"
class="input"
type="text"
placeholder="可选,如:A-01"
/>
</div>
<div class="md:col-span-3">
<label class="block text-apple-body text-system-label-primary mb-2">描述</label>
<div class="field-stack md:col-span-2">
<label class="field-label">描述</label>
<textarea
v-model.trim="createForm.description"
class="input-apple"
class="input"
rows="2"
placeholder="可填写放置区域、注意事项等"
></textarea>
</div>
</div>
<button
class="btn-apple-primary mt-4"
class="btn-primary text-xs sm:text-sm mt-4"
:disabled="createLoading || !canCreate"
@click="handleCreate"
>
@ -63,106 +64,136 @@
</button>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<h2 class="text-apple-title">地址列表</h2>
<button class="btn-apple-secondary" :disabled="loading" @click="fetchLocations">
<!-- 列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">地址列表</h3>
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchLocations">
刷新
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">名称</th>
<th class="py-3 px-4">编码</th>
<th class="py-3 px-4">状态</th>
<th class="py-3 px-4">当前资产数量</th>
<th class="py-3 px-4">备注</th>
<th class="py-3 px-4 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="location in locations"
:key="location.id"
class="border-b border-system-gray-6 text-system-label-primary text-apple-body"
<!-- 移动端卡片列表 -->
<div class="mobile-card-list md:hidden">
<div v-for="location in locations" :key="location.id" class="mobile-card">
<div class="flex items-start justify-between">
<div>
<p class="font-medium text-text-primary">{{ location.name }}</p>
<p class="text-xs text-text-tertiary">
更新于 {{ formatDateTime(location.updated_at) }}
</p>
</div>
<span
class="badge"
:class="location.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
<td class="py-3 px-4">
<div class="font-medium">{{ location.name }}</div>
<div class="text-apple-footnote text-system-label-tertiary">
更新于 {{ formatDateTime(location.updated_at) }}
</div>
</td>
<td class="py-3 px-4 font-mono text-apple-footnote text-system-label-secondary">
{{ location.code || '-' }}
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="location.status === 'active' ? 'bg-apple-green/15 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ location.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td class="py-3 px-4 text-apple-body">
{{ location._count?.equipments ?? 0 }}
</td>
<td class="py-3 px-4 text-apple-footnote text-system-label-secondary max-w-xs">
<p class="line-clamp-2">
{{ location.description || '—' }}
</p>
</td>
<td class="py-3 px-4 text-right space-x-3">
<button class="text-apple-blue hover:underline" @click="openEdit(location)">
编辑
</button>
<button
class="text-apple-red hover:underline"
@click="toggleStatus(location)"
>
{{ location.status === 'active' ? '停用' : '启用' }}
</button>
</td>
</tr>
</tbody>
</table>
{{ location.status === 'active' ? '启用' : '停用' }}
</span>
</div>
<div class="mt-3 text-sm text-text-secondary space-y-1">
<p>编码: <span class="font-mono">{{ location.code || '-' }}</span></p>
<p>资产数量: {{ location._count?.equipments ?? 0 }}</p>
<p v-if="location.description" class="line-clamp-2">{{ location.description }}</p>
</div>
<div class="mt-3 table-action-group">
<button class="table-action table-action--primary" @click="openEdit(location)">编辑</button>
<button class="table-action table-action--warning" @click="toggleStatus(location)">
{{ location.status === 'active' ? '停用' : '启用' }}
</button>
</div>
</div>
</div>
<p v-if="!loading && locations.length === 0" class="mt-6 text-center text-system-label-tertiary">
<!-- 桌面端表格 -->
<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 class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="location in locations" :key="location.id">
<td>
<div class="font-medium">{{ location.name }}</div>
<div class="text-xs text-text-tertiary">
更新于 {{ formatDateTime(location.updated_at) }}
</div>
</td>
<td class="font-mono text-xs text-text-secondary">
{{ location.code || '-' }}
</td>
<td>
<span
class="badge"
:class="location.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ location.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td>
{{ location._count?.equipments ?? 0 }}
</td>
<td class="text-xs text-text-secondary max-w-xs">
<p class="line-clamp-2">
{{ location.description || '—' }}
</p>
</td>
<td class="text-right">
<div class="table-action-group justify-end">
<button class="table-action table-action--primary" @click="openEdit(location)">
编辑
</button>
<button class="table-action table-action--warning" @click="toggleStatus(location)">
{{ location.status === 'active' ? '停用' : '启用' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-if="!loading && locations.length === 0" class="mt-6 text-center text-text-tertiary">
暂无地址数据。
</p>
</section>
<div
v-if="editingLocation"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4"
>
<div class="w-full max-w-xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">编辑地址</h3>
<!-- 编辑弹窗 -->
<div v-if="editingLocation" 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>
<label class="block text-apple-body text-system-label-primary mb-2">地址名称 *</label>
<input v-model.trim="editDraft.name" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">地址名称 *</label>
<input v-model.trim="editDraft.name" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">地址编码</label>
<input v-model.trim="editDraft.code" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">地址编码</label>
<input v-model.trim="editDraft.code" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="editDraft.status" class="input-apple">
<div class="field-stack">
<label class="field-label">状态</label>
<select v-model="editDraft.status" class="select">
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">描述</label>
<textarea v-model.trim="editDraft.description" class="input-apple" rows="3"></textarea>
<div class="field-stack">
<label class="field-label">描述</label>
<textarea v-model.trim="editDraft.description" class="input" rows="3"></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button class="btn-apple-secondary" @click="closeEdit">取消</button>
<button class="btn-apple-primary" :disabled="updateLoading" @click="submitEdit">
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeEdit">取消</button>
<button class="btn-primary" :disabled="updateLoading" @click="submitEdit">
{{ updateLoading ? '保存中...' : '保存修改' }}
</button>
</div>
@ -304,4 +335,3 @@ onMounted(() => {
fetchLocations()
})
</script>

View File

@ -1,41 +1,39 @@
<template>
<div class="space-y-6">
<section class="card-apple">
<div class="rounded-apple bg-system-gray-6 px-4 py-3 text-apple-footnote text-system-label-secondary">
这里管理的是系统登录账号,不会跟随钉钉人员同步自动增减。
</div>
<h2 class="text-apple-title mt-4 mb-4">新增用户</h2>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">用户名 *</label>
<input v-model.trim="newUser.username" class="input-apple" type="text" placeholder="登录用户名" />
<!-- 新增表单 -->
<section class="card">
<h3 class="section-title mb-4">新增用户</h3>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">用户名 *</label>
<input v-model.trim="newUser.username" class="input" type="text" placeholder="登录用户名" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">姓名 *</label>
<input v-model.trim="newUser.name" class="input-apple" type="text" placeholder="显示姓名" />
<div class="field-stack">
<label class="field-label">姓名 *</label>
<input v-model.trim="newUser.name" class="input" type="text" placeholder="显示姓名" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">密码 *</label>
<input v-model="newUser.password" class="input-apple" type="password" placeholder="初始密码" />
<div class="field-stack">
<label class="field-label">密码 *</label>
<input v-model="newUser.password" class="input" type="password" placeholder="初始密码" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">角色 *</label>
<select v-model="newUser.role" class="input-apple">
<div class="field-stack">
<label class="field-label">角色 *</label>
<select v-model="newUser.role" class="select">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">邮箱</label>
<input v-model.trim="newUser.email" class="input-apple" type="email" placeholder="邮箱(可选)" />
<div class="field-stack">
<label class="field-label">邮箱</label>
<input v-model.trim="newUser.email" class="input" type="email" placeholder="邮箱(可选)" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">手机号</label>
<input v-model.trim="newUser.phone" class="input-apple" type="tel" placeholder="手机号(可选)" />
<div class="field-stack">
<label class="field-label">手机号</label>
<input v-model.trim="newUser.phone" class="input" type="tel" placeholder="手机号(可选)" />
</div>
</div>
<button
class="btn-apple-primary mt-4"
class="btn-primary text-xs sm:text-sm mt-4"
:disabled="createLoading || !canCreate"
@click="handleCreateUser"
>
@ -43,119 +41,158 @@
</button>
</section>
<section class="card-apple">
<div class="flex items-center justify-between mb-4">
<h2 class="text-apple-title">用户列表</h2>
<button class="btn-apple-secondary" :disabled="listLoading" @click="fetchUsers">
<!-- 列表 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">用户列表</h3>
<button class="btn-secondary text-xs sm:text-sm" :disabled="listLoading" @click="fetchUsers">
刷新
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">用户名</th>
<th class="py-3 px-4">姓名</th>
<th class="py-3 px-4">角色</th>
<th class="py-3 px-4">状态</th>
<th class="py-3 px-4">联系方式</th>
<th class="py-3 px-4">最近登录</th>
<th class="py-3 px-4 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-b border-system-gray-6 text-apple-body text-system-label-primary"
>
<td class="py-3 px-4 font-mono">{{ user.username }}</td>
<td class="py-3 px-4">{{ user.name }}</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="user.role === 'admin' ? 'bg-apple-orange/10 text-apple-orange' : 'bg-system-gray-6 text-system-label-secondary'"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</span>
</td>
<td class="py-3 px-4">
<span
class="rounded-apple px-3 py-1 text-apple-footnote"
:class="user.status === 'active' ? 'bg-apple-green/10 text-apple-green' : 'bg-system-gray-6 text-system-label-tertiary'"
>
{{ user.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="py-3 px-4">
<div class="text-apple-footnote text-system-label-secondary space-y-1">
<p v-if="user.email">📧 {{ user.email }}</p>
<p v-if="user.phone">📞 {{ user.phone }}</p>
</div>
</td>
<td class="py-3 px-4 text-apple-footnote text-system-label-secondary">
{{ user.last_login_at ? formatDateTime(user.last_login_at) : '-' }}
</td>
<td class="py-3 px-4 text-right space-x-2">
<button class="text-apple-blue hover:underline" @click="openEdit(user)">编辑</button>
<button class="text-apple-orange hover:underline" @click="handleResetPassword(user)">
重置密码
</button>
<button class="text-apple-red hover:underline" @click="toggleStatus(user)">
{{ user.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="text-apple-red hover:underline" @click="handleDeleteUser(user)">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 移动端卡片列表 -->
<div class="mobile-card-list md:hidden">
<div v-for="user in users" :key="user.id" class="mobile-card">
<div class="flex items-start justify-between">
<div>
<p class="font-medium text-text-primary">{{ user.name }}</p>
<p class="text-sm text-text-secondary font-mono">{{ user.username }}</p>
</div>
<div class="flex gap-1">
<span
class="badge"
:class="user.role === 'admin' ? 'badge-warning' : 'badge-neutral'"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</span>
<span
class="badge"
:class="user.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ user.status === 'active' ? '启用' : '禁用' }}
</span>
</div>
</div>
<div class="mt-3 text-sm text-text-secondary space-y-1">
<p v-if="user.email">{{ user.email }}</p>
<p v-if="user.phone">{{ user.phone }}</p>
<p>最近登录: {{ user.last_login_at ? formatDateTime(user.last_login_at) : '-' }}</p>
</div>
<div class="mt-3 table-action-group">
<button class="table-action table-action--primary" @click="openEdit(user)">编辑</button>
<button class="table-action table-action--warning" @click="handleResetPassword(user)">重置密码</button>
<button class="table-action" @click="toggleStatus(user)">
{{ user.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDeleteUser(user)">删除</button>
</div>
</div>
</div>
<p v-if="!listLoading && users.length === 0" class="mt-6 text-center text-system-label-tertiary">
<!-- 桌面端表格 -->
<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>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="font-mono text-sm">{{ user.username }}</td>
<td class="font-medium">{{ user.name }}</td>
<td>
<span
class="badge"
:class="user.role === 'admin' ? 'badge-warning' : 'badge-neutral'"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</span>
</td>
<td>
<span
class="badge"
:class="user.status === 'active' ? 'badge-success' : 'badge-neutral'"
>
{{ user.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td>
<div class="text-sm text-text-secondary space-y-1">
<p v-if="user.email">{{ user.email }}</p>
<p v-if="user.phone">{{ user.phone }}</p>
</div>
</td>
<td class="text-sm text-text-secondary">
{{ user.last_login_at ? formatDateTime(user.last_login_at) : '-' }}
</td>
<td class="text-right">
<div class="table-action-group justify-end">
<button class="table-action table-action--primary" @click="openEdit(user)">编辑</button>
<button class="table-action table-action--warning" @click="handleResetPassword(user)">
重置密码
</button>
<button class="table-action" @click="toggleStatus(user)">
{{ user.status === 'active' ? '禁用' : '启用' }}
</button>
<button class="table-action table-action--danger" @click="handleDeleteUser(user)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-if="!listLoading && users.length === 0" class="mt-6 text-center text-text-tertiary">
暂无用户数据。
</p>
</section>
<div
v-if="editingUser"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4"
>
<div class="w-full max-w-xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl">
<h3 class="text-apple-title mb-4">编辑用户</h3>
<!-- 编辑弹窗 -->
<div v-if="editingUser" 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>
<label class="block text-apple-body text-system-label-primary mb-2">姓名</label>
<input v-model.trim="editingUserDraft.name" class="input-apple" type="text" />
<div class="field-stack">
<label class="field-label">姓名</label>
<input v-model.trim="editingUserDraft.name" class="input" type="text" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">角色</label>
<select v-model="editingUserDraft.role" class="input-apple">
<div class="field-stack">
<label class="field-label">角色</label>
<select v-model="editingUserDraft.role" class="select">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-apple-body text-system-label-primary mb-2">邮箱</label>
<input v-model.trim="editingUserDraft.email" class="input-apple" type="email" />
<div class="field-grid">
<div class="field-stack">
<label class="field-label">邮箱</label>
<input v-model.trim="editingUserDraft.email" class="input" type="email" />
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">手机号</label>
<input v-model.trim="editingUserDraft.phone" class="input-apple" type="tel" />
<div class="field-stack">
<label class="field-label">手机号</label>
<input v-model.trim="editingUserDraft.phone" class="input" type="tel" />
</div>
</div>
<div>
<label class="block text-apple-body text-system-label-primary mb-2">状态</label>
<select v-model="editingUserDraft.status" class="input-apple">
<div class="field-stack">
<label class="field-label">状态</label>
<select v-model="editingUserDraft.status" class="select">
<option value="active">启用</option>
<option value="inactive">禁用</option>
</select>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button class="btn-apple-secondary" @click="closeEdit">取消</button>
<button class="btn-apple-primary" :disabled="updateLoading" @click="submitEdit">
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="closeEdit">取消</button>
<button class="btn-primary" :disabled="updateLoading" @click="submitEdit">
{{ updateLoading ? '保存中...' : '保存修改' }}
</button>
</div>

View File

@ -1,14 +1,11 @@
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Value Analytics</p>
<h2 class="page-title">资产统计分析</h2>
<p class="page-subtitle">
这里重点看价值结构和高价值资产,适合做盘点准备、采购复盘和对外汇报。
</p>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">总资产价值</p>
@ -17,135 +14,164 @@
</div>
<div class="page-meta">
<p class="page-meta__label">可用价值</p>
<p class="page-meta__value">{{ formatCurrencyNoFraction(summary.available_value) }}</p>
<p class="page-meta__value text-status-success">{{ formatCurrencyNoFraction(summary.available_value) }}</p>
<p class="page-meta__subvalue">可立即调拨与借用</p>
</div>
<div class="page-meta">
<p class="page-meta__label">借出价值</p>
<p class="page-meta__value">{{ formatCurrencyNoFraction(summary.borrowed_value) }}</p>
<p class="page-meta__value text-status-warning">{{ formatCurrencyNoFraction(summary.borrowed_value) }}</p>
<p class="page-meta__subvalue">当前在外资产</p>
</div>
</div>
</section>
<section class="toolbar-panel">
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">资产类型</label>
<select v-model="filters.equipment_type" class="input-apple" @change="handleSearch">
<!-- 筛选 -->
<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>
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">开始日期</label>
<input v-model="filters.start_date" type="date" class="input-apple" />
<div class="field-stack">
<label class="field-label">开始日期</label>
<input v-model="filters.start_date" type="date" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">结束日期</label>
<input v-model="filters.end_date" type="date" class="input-apple" />
<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">
<button class="btn-apple-secondary" @click="handleReset">重置</button>
<button class="btn-apple-primary" :disabled="loading" @click="handleSearch">
<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>
<div class="metric-grid">
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">总资产价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-system-label-primary">
<!-- 统计卡片 -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">总资产价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-text-primary">
{{ formatCurrencyNoFraction(summary.total_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">当前筛选条件下的全部资产价值。</p>
</div>
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">可用价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-apple-green">
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">可用价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-status-success">
{{ formatCurrencyNoFraction(summary.available_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">随时可调拨、借出的资产价值。</p>
</div>
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">借出价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-apple-orange">
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">借出价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-status-warning">
{{ formatCurrencyNoFraction(summary.borrowed_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">当前在外流转资产的价值规模。</p>
</div>
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">维护价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-apple-yellow">
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">维护价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-status-error">
{{ formatCurrencyNoFraction(summary.maintenance_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">维修状态资产对应的价值体量。</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[0.92fr,1.08fr]">
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">价值结构占比</h3>
<p class="section-subtitle">先看可用、借出和维护中的价值分布。</p>
</div>
<span class="chip-muted">价值口径</span>
<!-- 图表 -->
<div class="grid gap-6 lg:grid-cols-2">
<section class="card">
<div class="list-header">
<h3 class="section-title">价值结构占比</h3>
<span class="badge badge-neutral">价值口径</span>
</div>
<div ref="valueChartRef" class="mt-5 h-80"></div>
<div ref="valueChartRef" class="h-80"></div>
</section>
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">按类型的价值结构</h3>
<p class="section-subtitle">哪些类型价值高、占比重,一眼能看出来。</p>
</div>
<span class="chip-muted">总值 / 借出 / 可用</span>
<section class="card">
<div class="list-header">
<h3 class="section-title">按类型的价值结构</h3>
<span class="badge badge-neutral">总值 / 借出 / 可用</span>
</div>
<div ref="typeChartRef" class="mt-5 h-80"></div>
<div ref="typeChartRef" class="h-80"></div>
</section>
</div>
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">高价值资产 Top 10</h3>
<p class="section-subtitle">适合盘点时优先关注,也适合对外汇报重点资产构成。</p>
</div>
<span class="chip-muted">按总价值排序</span>
<!-- Top 10 表格 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">高价值单件资产 Top 10</h3>
<span class="badge badge-neutral">按单件价值排序</span>
</div>
<div class="table-shell mt-5">
<div class="overflow-x-auto">
<table class="data-table min-w-[760px]">
<!-- 移动端:卡片列表 -->
<div class="mobile-card-list md:hidden">
<article v-for="(item, index) in statistics?.top_equipment || []" :key="`${item.equipment_name}-${index}`" class="mobile-card">
<div class="mobile-card__row">
<div class="flex items-center gap-3">
<span class="badge" :class="index < 3 ? 'badge-success' : 'badge-neutral'">
{{ String(index + 1).padStart(2, '0') }}
</span>
<p class="text-sm font-semibold text-text-primary">{{ item.equipment_name }}</p>
</div>
</div>
<div class="mobile-card__meta mt-3">
<div>
<p class="mobile-card__label">单件价值</p>
<p class="mobile-card__value">{{ formatCurrencyNoFraction(item.item_value) }}</p>
</div>
<div>
<p class="mobile-card__label">库存数量</p>
<p class="mobile-card__value">{{ item.quantity }}</p>
</div>
<div>
<p class="mobile-card__label">录入单价</p>
<p class="mobile-card__value">{{ formatCurrencyNoFraction(item.unit_price) }}</p>
</div>
<div>
<p class="mobile-card__label">库存总值</p>
<p class="mobile-card__value text-text-primary font-semibold">{{ formatCurrencyNoFraction(item.stock_total_value) }}</p>
</div>
</div>
</article>
<div v-if="!statistics?.top_equipment?.length" class="empty-state">
当前筛选条件下暂无高价值资产数据
</div>
</div>
<!-- 桌面端:表格 -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th class="text-left">排名</th>
<th class="text-left">资产名称</th>
<th class="text-left">单价</th>
<th class="text-left">总价值</th>
<th class="text-left">借出价值</th>
<th class="text-left">可用价值</th>
<th>排名</th>
<th>资产名称</th>
<th>单件价值</th>
<th>库存数量</th>
<th>录入单价</th>
<th>库存总值</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in statistics?.top_equipment || []" :key="`${item.equipment_name}-${index}`">
<td>
<span class="chip-muted">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="badge" :class="index < 3 ? 'badge-success' : 'badge-neutral'">
{{ String(index + 1).padStart(2, '0') }}
</span>
</td>
<td class="font-medium text-system-label-primary">{{ item.equipment_name }}</td>
<td class="text-system-label-secondary">{{ formatCurrencyNoFraction(item.unit_price) }}</td>
<td class="font-medium text-system-label-primary">{{ formatCurrencyNoFraction(item.total_value) }}</td>
<td class="text-apple-orange">{{ formatCurrencyNoFraction(item.borrowed_value) }}</td>
<td class="text-apple-green">{{ formatCurrencyNoFraction(item.available_value) }}</td>
<td class="font-medium text-text-primary">{{ item.equipment_name }}</td>
<td class="font-medium text-text-primary">{{ formatCurrencyNoFraction(item.item_value) }}</td>
<td class="text-text-secondary">{{ item.quantity }}</td>
<td class="text-text-secondary">{{ formatCurrencyNoFraction(item.unit_price) }}</td>
<td class="font-medium text-text-primary">{{ formatCurrencyNoFraction(item.stock_total_value) }}</td>
</tr>
<tr v-if="!statistics?.top_equipment?.length">
<td colspan="6" class="text-center text-sm text-system-label-secondary">当前筛选条件下暂无高价值资产数据。</td>
<td colspan="6" class="text-center text-sm text-text-secondary py-8">当前筛选条件下暂无高价值资产数据。</td>
</tr>
</tbody>
</table>
@ -223,13 +249,30 @@ const initCharts = (data: ValueStatistics) => {
typeChart = echarts.init(typeChartRef.value)
}
// 深色主题配色
const darkColors = {
available: '#10b981', // status-success
borrowed: '#f59e0b', // status-warning
maintenance: '#ef4444', // status-danger
total: '#6366f1', // accent-primary
text: '#e2e8f0', // text-primary
textSecondary: '#94a3b8', // text-secondary
grid: 'rgba(148, 163, 184, 0.1)'
}
valueChart?.setOption({
tooltip: { trigger: 'item' },
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#1e293b',
borderColor: '#334155',
textStyle: { color: '#e2e8f0' }
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#6b7280'
color: '#94a3b8'
}
},
series: [
@ -239,27 +282,32 @@ const initCharts = (data: ValueStatistics) => {
center: ['50%', '43%'],
label: {
formatter: '{b}\n{d}%',
color: '#5f6675'
color: '#94a3b8'
},
data: [
{ value: data.available_value, name: '可用价值', itemStyle: { color: '#34C759' } },
{ value: data.borrowed_value, name: '借出价值', itemStyle: { color: '#FF9500' } },
{ value: data.maintenance_value, name: '维护价值', itemStyle: { color: '#FFCC00' } }
{ value: data.available_value, name: '可用价值', itemStyle: { color: darkColors.available } },
{ value: data.borrowed_value, name: '借出价值', itemStyle: { color: darkColors.borrowed } },
{ value: data.maintenance_value, name: '维护价值', itemStyle: { color: darkColors.maintenance } }
]
}
]
})
typeChart?.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
backgroundColor: '#1e293b',
borderColor: '#334155',
textStyle: { color: '#e2e8f0' }
},
legend: {
bottom: 0,
data: ['总价值', '借出价值', '可用价值']
data: ['总价值', '借出价值', '可用价值'],
textStyle: { color: '#94a3b8' }
},
grid: {
left: 0,
@ -271,30 +319,35 @@ const initCharts = (data: ValueStatistics) => {
xAxis: {
type: 'category',
axisTick: { show: false },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8' },
data: data.value_by_type.map((item) => item.equipment_type)
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(99, 102, 112, 0.08)' } }
splitLine: { lineStyle: { color: darkColors.grid } },
axisLabel: { color: '#94a3b8' }
},
series: [
{
name: '总价值',
type: 'bar',
barWidth: 18,
itemStyle: { color: '#355C7D', borderRadius: [6, 6, 0, 0] },
itemStyle: { color: darkColors.total, borderRadius: [4, 4, 0, 0] },
data: data.value_by_type.map((item) => item.total_value)
},
{
name: '借出价值',
type: 'bar',
itemStyle: { color: '#F08A5D', borderRadius: [6, 6, 0, 0] },
barWidth: 18,
itemStyle: { color: darkColors.borrowed, borderRadius: [4, 4, 0, 0] },
data: data.value_by_type.map((item) => item.borrowed_value)
},
{
name: '可用价值',
type: 'bar',
itemStyle: { color: '#4D8076', borderRadius: [6, 6, 0, 0] },
barWidth: 18,
itemStyle: { color: darkColors.available, borderRadius: [4, 4, 0, 0] },
data: data.value_by_type.map((item) => item.available_value)
}
]

View File

@ -7,65 +7,104 @@ export default {
theme: {
extend: {
colors: {
// Apple Design System Colors
apple: {
blue: '#007AFF',
green: '#34C759',
indigo: '#5856D6',
orange: '#FF9500',
pink: '#FF2D55',
purple: '#AF52DE',
red: '#FF3B30',
teal: '#5AC8FA',
yellow: '#FFCC00',
surface: '#242424',
background: '#1a1a1a',
// 深色主题背景
bg: {
primary: '#0d0d0d',
secondary: '#141414',
tertiary: '#1a1a1a',
elevated: '#242424',
hover: '#2a2a2a',
},
// 文字色
text: {
primary: '#ffffff',
secondary: '#a1a1a1',
tertiary: '#6e6e6e',
},
// 边框
border: {
DEFAULT: '#2a2a2a',
hover: '#3a3a3a',
},
// 强调色 - 青绿色系
accent: {
primary: '#10a37f',
DEFAULT: '#10a37f',
hover: '#0d8c6d',
secondary: '#19c59f',
},
// 状态色
status: {
success: '#10a37f',
warning: '#f59e0b',
error: '#ef4444',
danger: '#ef4444',
neutral: '#6b7280',
},
apple: {
blue: '#0a84ff',
green: '#30d158',
indigo: '#5856d6',
orange: '#ff9f0a',
pink: '#ff375f',
purple: '#bf5af2',
red: '#ff453a',
teal: '#64d2ff',
yellow: '#ffd60a'
},
// System Colors
system: {
gray: {
1: '#8E8E93',
2: '#AEAEB2',
3: '#C7C7CC',
4: '#D1D1D6',
5: '#E5E5EA',
6: '#F2F2F7',
},
background: {
primary: '#FFFFFF',
secondary: '#F2F2F7',
tertiary: '#FFFFFF',
primary: '#1a1a1a',
secondary: '#2a2a2a',
tertiary: '#3a3a3a'
},
gray: {
1: '#8e8e93',
2: '#636366',
3: '#48484a',
4: '#3a3a3c',
5: '#2c2c2e',
6: '#1c1c1e'
},
label: {
primary: '#000000',
secondary: '#3C3C43',
tertiary: '#3C3C4399',
quaternary: '#3C3C434D',
},
},
primary: '#ffffff',
secondary: '#98989d',
tertiary: '#6c6c70',
quaternary: '#4b5563'
}
}
},
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'"SF Pro Display"',
'"SF Pro Text"',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Helvetica',
'Arial',
'sans-serif',
],
},
borderRadius: {
'apple': '10px',
'apple-lg': '14px',
'apple-xl': '20px',
'sm': '6px',
'md': '8px',
'lg': '12px',
'xl': '16px',
'full': '9999px',
},
boxShadow: {
'apple': '0 2px 8px rgba(0, 0, 0, 0.1)',
'apple-lg': '0 4px 16px rgba(0, 0, 0, 0.15)',
'apple-xl': '0 8px 32px rgba(0, 0, 0, 0.2)',
spacing: {
'xs': '4px',
'sm': '8px',
'md': '12px',
'lg': '16px',
'xl': '24px',
'2xl': '32px',
},
backdropBlur: {
'apple': '20px',
transitionDuration: {
'fast': '150ms',
'normal': '250ms',
},
},
},
@ -73,4 +112,3 @@ export default {
require('@tailwindcss/forms'),
],
}

View File

@ -75,6 +75,9 @@ echo ">>> 备份部署配置"
cp "${PROJECT_DIR}/docker-compose.yml" "${BACKUP_DIR}/config/docker-compose.yml"
cp "${PROJECT_DIR}/deploy.sh" "${BACKUP_DIR}/config/deploy.sh"
cp "${PROJECT_DIR}/Dockerfile" "${BACKUP_DIR}/config/Dockerfile"
if [ -d "${PROJECT_DIR}/frontend/public/branding" ]; then
cp -R "${PROJECT_DIR}/frontend/public/branding" "${BACKUP_DIR}/config/branding"
fi
cat > "${BACKUP_DIR}/backup-info.txt" <<EOF
timestamp=${TIMESTAMP}
@ -90,7 +93,13 @@ EOF
echo ">>> 生成校验信息"
(
cd "${BACKUP_DIR}"
sha256sum db/${DB_NAME}.sql.gz uploads/uploads.tar.gz config/docker-compose.yml config/deploy.sh config/Dockerfile > SHA256SUMS
if [ -d config/branding ]; then
find config/branding -type f -print0 | xargs -0 sha256sum > branding-sha256.tmp
else
: > branding-sha256.tmp
fi
cat <(sha256sum db/${DB_NAME}.sql.gz uploads/uploads.tar.gz config/docker-compose.yml config/deploy.sh config/Dockerfile) branding-sha256.tmp > SHA256SUMS
rm -f branding-sha256.tmp
)
prune_old_backups

466
前端视觉规范.md Normal file
View File

@ -0,0 +1,466 @@
# 固定资产管理系统 - 前端视觉规范
## 1. 设计原则
### 1.1 核心理念
- **极简主义**:去除多余装饰,保持界面清爽
- **一致性**:所有页面使用统一的组件和样式
- **可读性**:文字清晰,层级分明
- **深色主题**:统一的深色背景,保护视力
### 1.2 禁止事项
- ❌ 不要使用渐变色按钮(除非特殊强调)
- ❌ 不要使用圆角过大的元素(保持 8-16px)
- ❌ 不要使用阴影过重的卡片
- ❌ 不要在同一页面混用不同的间距(统一使用 gap-4/gap-6)
- ❌ 不要添加过多的说明文字(只保留必要的)
---
## 2. 颜色系统
### 2.1 背景色(Background)
```css
--bg-primary: #0d0d0d /* 页面主背景 */
--bg-secondary: #141414 /* 卡片背景 */
--bg-tertiary: #1a1a1a /* 次级背景 */
--bg-elevated: #242424 /* 输入框、浮层背景 */
--bg-hover: #2a2a2a /* 悬停状态 */
```
**使用规范:**
- 页面背景:`bg-bg-primary`
- 卡片容器:`bg-bg-secondary`
- 输入框、选择框:`bg-bg-elevated`
- 悬停效果:`hover:bg-bg-hover`
### 2.2 文字色(Text)
```css
--text-primary: #ffffff /* 主标题、重要文字 */
--text-secondary: #a1a1a1 /* 正文、描述 */
--text-tertiary: #6e6e6e /* 辅助文字、占位符 */
```
**使用规范:**
- 页面标题:`text-text-primary`
- 正文内容:`text-text-secondary`
- 提示文字、时间戳:`text-text-tertiary`
### 2.3 边框色(Border)
```css
--border-color: #2a2a2a /* 默认边框 */
--border-hover: #3a3a3a /* 悬停边框 */
```
**使用规范:**
- 所有边框统一使用:`border-border`
- 悬停状态:`hover:border-border-hover`
### 2.4 强调色(Accent)
```css
--accent-primary: #10a37f /* 主按钮、链接 */
--accent-primary-hover: #0d8c6d /* 按钮悬停 */
--accent-secondary: #19c59f /* 次要强调 */
```
**使用规范:**
- 主按钮背景:`bg-accent-primary`
- 链接文字:`text-accent-primary`
- 悬停状态:`hover:bg-accent-primary-hover`
### 2.5 状态色(Status)
```css
--status-success: #10a37f /* 成功、可用 */
--status-warning: #f59e0b /* 警告、借出 */
--status-error: #ef4444 /* 错误、删除 */
--status-neutral: #6b7280 /* 中性、禁用 */
```
**使用规范:**
- 成功状态:`text-status-success` / `bg-status-success/15`
- 警告状态:`text-status-warning` / `bg-status-warning/15`
- 错误状态:`text-status-error` / `bg-status-error/15`
---
## 3. 组件规范
### 3.1 按钮(Button)
#### 主按钮(Primary)
```html
<button class="btn-primary">确认</button>
```
**样式特征:**
- 背景:`bg-accent-primary`
- 文字:白色
- 圆角:`8px`
- 高度:`44px`
- 阴影:`0 8px 24px rgba(16, 163, 127, 0.22)`
#### 次要按钮(Secondary)
```html
<button class="btn-secondary">取消</button>
```
**样式特征:**
- 背景:`bg-bg-elevated`
- 边框:`1px solid border-color`
- 文字:`text-text-secondary`
- 圆角:`8px`
- 高度:`44px`
#### 图标按钮(Icon Button)
```html
<button class="btn-icon">
<svg>...</svg>
</button>
```
**禁止:**
- ❌ 不要使用 `btn-apple-primary` / `btn-apple-secondary`(已废弃)
### 3.2 输入框(Input)
#### 文本输入
```html
<input type="text" class="input" placeholder="请输入..." />
```
**样式特征:**
- 背景:`bg-bg-elevated`
- 边框:`1px solid border-color`
- 圆角:`8px`
- 高度:`44px`
- 内边距:`10px 14px`
- 聚焦:`border-accent-primary`
#### 选择框(Select)
```html
<select class="select">
<option>选项</option>
</select>
```
**禁止:**
- ❌ 不要使用 `input-apple`(已废弃)
### 3.3 卡片(Card)
#### 标准卡片
```html
<section class="card">
<h3 class="section-title">标题</h3>
<!-- 内容 -->
</section>
```
**样式特征:**
- 背景:`bg-bg-secondary`
- 边框:`1px solid border-color`
- 圆角:`12px`(移动端 `8px`)
- 内边距:`16px`(移动端 `12px`)
- 阴影:`0 16px 44px rgba(0, 0, 0, 0.18)`
#### 面板(Panel)
```html
<div class="panel">内容</div>
```
**样式特征:**
- 背景:`bg-bg-elevated`
- 边框:`1px solid border-color`
- 圆角:`12px`
- 内边距:`12px`
**禁止:**
- ❌ 不要使用 `card-apple`(已废弃)
### 3.4 标签(Badge)
```html
<span class="badge badge-success">成功</span>
<span class="badge badge-warning">警告</span>
<span class="badge badge-error">错误</span>
<span class="badge badge-neutral">中性</span>
```
**样式特征:**
- 圆角:`9999px`(全圆角)
- 内边距:`2px 8px`
- 字体大小:`12px`
### 3.5 表格(Table)
```html
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>列标题</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据</td>
</tr>
</tbody>
</table>
</div>
```
**样式特征:**
- 表头背景:`bg-bg-tertiary`
- 表头文字:`text-text-tertiary`,大写,字间距 `0.05em`
- 行分隔线:`border-border`
- 悬停:`hover:bg-bg-hover`
---
## 4. 布局规范
### 4.1 页面结构
```html
<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">
<!-- 内容 -->
</section>
</div>
```
### 4.2 间距规范
- 大区块间距:`space-y-6`(24px)
- 卡片内间距:`gap-4`(16px)
- 表单字段间距:`gap-4`(16px)
- 按钮组间距:`gap-3`(12px)
- 文字行间距:`mt-1`、`mt-2`(4px、8px)
### 4.3 响应式断点
- 移动端:`< 640px`
- 平板:`640px - 1024px`
- 桌面:`>= 1024px`
**移动端适配原则:**
- 卡片内边距减小到 `12px`
- 圆角减小到 `8px`
- 表格转为卡片列表
- 按钮全宽显示
---
## 5. 字体规范
### 5.1 字体大小
| 级别 | 类名 | 大小 | 用途 |
|------|------|------|------|
| 页面标题 | `text-2xl font-semibold` | 24px | 页面主标题 |
| 区块标题 | `text-xl font-semibold` | 20px | 卡片标题 |
| 小节标题 | `text-lg font-semibold` | 18px | 子区块标题 |
| 正文 | `text-sm` | 14px | 普通文字 |
| 辅助文字 | `text-xs` | 12px | 标签、时间戳 |
| 大数字 | `text-2xl font-semibold` | 24px | 统计数据 |
### 5.2 字体粗细
- 标题:`font-semibold`(600)
- 正文:`font-normal`(400)
- 强调:`font-medium`(500)
### 5.3 字间距
- 英文标签:`tracking-wider`,大写
- 数字:`font-mono`
---
## 6. 页面头部规范
### 6.1 标准页面头部
```html
<section class="page-header">
<div>
<p class="page-kicker">英文标签</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">数值</p>
</div>
</div>
</section>
```
**样式特征:**
- Kicker:`text-xs`,`text-text-tertiary`,大写,字间距 `0.1em`
- 标题:`text-2xl font-semibold`,`text-text-primary`
- 元信息卡片:`bg-bg-elevated`,圆角 `12px`
### 6.2 禁止
- ❌ 不要在页面头部添加 `page-subtitle`(已废弃)
- ❌ 不要使用 `surface-note` 说明框(已废弃)
---
## 7. 表单规范
### 7.1 表单字段
```html
<div class="field-stack">
<label class="field-label">字段标签</label>
<input type="text" class="input" placeholder="提示文字" />
<p class="field-hint">辅助说明(可选)</p>
</div>
```
**样式特征:**
- 标签:`text-sm font-semibold`,`text-text-primary`
- 输入框:`input` 类
- 提示:`text-xs`,`text-text-tertiary`
### 7.2 表单布局
```html
<div class="field-grid">
<!-- 两列布局 -->
</div>
<div class="field-grid--3">
<!-- 三列布局 -->
</div>
```
---
## 8. 快速参考
### 8.1 常用类名速查
```
/* 按钮 */
btn-primary, btn-secondary, btn-icon
/* 输入 */
input, select
/* 卡片 */
card, panel, card-elevated
/* 文字颜色 */
text-text-primary, text-text-secondary, text-text-tertiary
/* 背景颜色 */
bg-bg-primary, bg-bg-secondary, bg-bg-elevated
/* 边框 */
border-border
/* 状态 */
badge-success, badge-warning, badge-error, badge-neutral
text-status-success, text-status-warning, text-status-error
/* 布局 */
page-header, page-meta, page-meta-grid
section-title, field-stack, field-grid
```
### 8.2 已废弃类名(禁止使用)
```
btn-apple-primary, btn-apple-secondary
input-apple, card-apple
text-apple-*, bg-apple-*
text-system-label-*, bg-system-gray-*, border-system-gray-*
rounded-apple
surface-note, page-subtitle, section-subtitle
```
---
## 9. 示例代码
### 9.1 列表页面模板
```vue
<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">List</p>
<h2 class="page-title">列表标题</h2>
</div>
</section>
<!-- 筛选区 -->
<section class="card">
<div class="grid gap-4 md:grid-cols-3">
<input type="text" class="input" placeholder="搜索..." />
<select class="select">
<option>全部</option>
</select>
<div class="flex gap-3">
<button class="btn-secondary">重置</button>
<button class="btn-primary">查询</button>
</div>
</div>
</section>
<!-- 数据表格 -->
<section class="card">
<div class="table-container">
<table class="data-table">
<!-- 表格内容 -->
</table>
</div>
</section>
</div>
</template>
```
### 9.2 表单页面模板
```vue
<template>
<div class="form-shell">
<section class="form-hero">
<div class="field-grid">
<div class="field-stack">
<label class="field-label">字段名 *</label>
<input type="text" class="input" required />
</div>
</div>
</section>
<section class="form-panel">
<div class="form-panel__header">
<h3 class="form-panel__title">区块标题</h3>
</div>
<div class="field-grid">
<!-- 表单字段 -->
</div>
</section>
</div>
</template>
```
---
## 10. 检查清单
在提交代码前,请确认:
- [ ] 没有使用已废弃的类名
- [ ] 按钮使用 `btn-primary` / `btn-secondary`
- [ ] 输入框使用 `input` / `select`
- [ ] 卡片使用 `card` / `panel`
- [ ] 颜色使用 `text-text-*` / `bg-bg-*`
- [ ] 边框使用 `border-border`
- [ ] 没有多余的说明文字
- [ ] 移动端样式已测试
- [ ] 间距统一使用 `gap-4` / `gap-6`
---
**最后更新:** 2026-03-17
**版本:** v1.0

856
前端视觉重构台账.md Normal file
View File

@ -0,0 +1,856 @@
# 固定资产管理系统 - 前端视觉重构台账
**重构日期**: 2026-03-17
**重构范围**: 全站前端视觉统一
**版本**: v2.0
---
## 一、重构概述
本次重构将项目前端视觉全面统一,建立了严格的视觉规范体系,确保所有页面风格一致、排版协调。
### 核心目标
1. **统一页面结构** - 所有页面遵循相同的布局层次
2. **统一组件样式** - 按钮、输入框、卡片等组件完全一致
3. **统一间距系统** - 严格执行 4px 基准的间距规范
4. **统一响应式策略** - 移动端/桌面端切换时机一致
---
## 二、关键文件清单
### 核心样式系统(1个)
| 文件路径 | 说明 | 变更类型 |
|---------|------|---------|
| `frontend/src/style.css` | 全局样式系统 | 全面重写 |
### 布局框架(1个)
| 文件路径 | 说明 | 变更类型 |
|---------|------|---------|
| `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同步 | 样式统一 |
**总计**: 20 个文件
---
## 三、视觉规范体系
### 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 标准列表页结构
```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>
<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>
<!-- 移动端:卡片列表 -->
<div class="mobile-card-list md:hidden">
<article class="mobile-card">
<div class="mobile-card__row">...</div>
<div class="mobile-card__meta">...</div>
</article>
</div>
<!-- 桌面端:表格 -->
<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>
</div>
</section>
</div>
</template>
```
### 4.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>
<!-- 表单卡片 -->
<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>
</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>
</template>
```
---
## 五、组件类名速查表
### 5.1 按钮
```vue
<!-- 主要按钮 -->
<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>
```
### 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>
```
### 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>
<span class="badge badge-error">错误</span>
<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">
<div class="mobile-card__row">
<p class="text-sm font-semibold text-text-primary">标题</p>
<span class="badge badge-success">状态</span>
</div>
<div class="mobile-card__meta">
<div>
<p class="mobile-card__label">标签</p>
<p class="mobile-card__value">值</p>
</div>
</div>
</article>
</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));
}
}
```
#### 页面移动端适配
| 页面 | 优化内容 |
|------|---------|
| `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` |
#### 移动端列表结构模板
```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 */
```
**文字简化原则:**
| 桌面端文字 | 移动端文字 |
|-----------|-----------|
| 全部标记已读 | 全标已读 |
| 刷新预览 | 刷新 |
| 立即同步 | 同步 |
| 通过审批 | 通过 |
| 驳回申请 | 驳回 |
| 完成维修 | 完成 |
| 取消维修 | 取消 |
| 完成处置 | 完成 |
| 新增使用者 | 新增 |
| 下载导入模板 | 下载模板 |
| 查看配件 N | 配件(N) |
| 收起配件 | 收起 |
| 打印标签 | 标签 |
**Vue 模板示例:**
```vue
<button class="btn-primary text-xs sm:text-sm">保存</button>
<button class="btn-secondary text-xs sm:text-sm">取消</button>
```
### 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) {
.badge {
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;
}
.swipe-item {
scroll-snap-align: start;
flex-shrink: 0;
}
```
---
**文档版本**: v2.0
**最后更新**: 2026-03-17
**维护者**: Claude

527
深色主题改造总结.md Normal file
View File

@ -0,0 +1,527 @@
# 固定资产管理系统 - 深色极简主题改造总结
## 改造概述
本次改造将原有的暖色调主题全面升级为深色极简风格,遵循 Apple Design 设计规范,使用深色背景、高对比度文字和统一的圆角设计。
---
## 一、全局样式配置
### 1.1 CSS 变量定义 (style.css)
```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;
--accent-secondary: #19c59f;
/* 状态色 */
--status-success: #10a37f;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-info: #3b82f6;
```
### 1.2 Tailwind 配置 (tailwind.config.js)
```javascript
colors: {
'bg': {
'primary': '#0d0d0d',
'secondary': '#141414',
'tertiary': '#1a1a1a',
'elevated': '#242424',
'hover': '#2a2a2a',
},
'text': {
'primary': '#ffffff',
'secondary': '#a1a1a1',
'tertiary': '#6e6e6e',
},
'border': '#2a2a2a',
'accent': {
'primary': '#10a37f',
'hover': '#0d8c6d',
'secondary': '#19c59f',
},
'status': {
'success': '#10a37f',
'warning': '#f59e0b',
'error': '#ef4444',
'info': '#3b82f6',
}
}
```
### 1.3 样式类命名规范
**重要:统一使用以下类名,避免混用多套命名体系**
| 类名 | 用途 | 样式说明 |
|------|------|----------|
| `card` / `card-apple` | 卡片容器 | 圆角16px,深色背景,细边框 |
| `input` / `input-apple` | 输入框 | 圆角8px,深色背景,聚焦时青绿边框 |
| `select` | 选择框 | 圆角8px,深色背景,带下拉箭头 |
| `btn-primary` / `btn-apple-primary` | 主按钮 | 青绿色背景,白色文字,圆角8px |
| `btn-secondary` / `btn-apple-secondary` | 次按钮 | 深色背景,灰色边框,圆角8px |
| `btn-icon` | 图标按钮 | 圆形,透明背景 |
| `badge` | 状态标签 | 圆角,带背景色 |
### 1.4 按钮规范
**尺寸规范:**
- 高度:44px (min-height)
- 内边距:px-4 py-2.5
- 圆角:8px (radius-md)
- 字体:text-sm
- 按钮间距:gap-2
**使用示例:**
```vue
<!-- 主按钮 -->
<button class="btn-primary text-sm">确认</button>
<!-- 次按钮 -->
<button class="btn-secondary text-sm">取消</button>
<!-- 按钮组 -->
<div class="flex flex-wrap gap-2">
<button class="btn-primary text-sm">保存</button>
<button class="btn-secondary text-sm">取消</button>
</div>
```
### 1.5 输入框规范
**尺寸规范:**
- 高度:44px (min-height)
- 内边距:py-2.5 px-3.5 (桌面端), py-3 px-3.5 (移动端)
- 圆角:8px (radius-md)
- 字体:text-sm (桌面端), text-base (移动端防止iOS缩放)
**使用示例:**
```vue
<!-- 文本输入 -->
<input type="text" class="input" placeholder="请输入" />
<!-- 选择框 -->
<select class="select">
<option value="">请选择</option>
</select>
<!-- 日期选择 -->
<input type="date" class="input" />
```
### 1.6 响应式布局规范
**断点定义:**
- sm: 640px
- md: 768px
- lg: 1024px
- xl: 1280px
**常用布局模式:**
```vue
<!-- 筛选器 - 移动端1列,平板2列,桌面4列 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<input class="input" />
<select class="select"></select>
<input class="input" />
<button class="btn-secondary w-full sm:w-auto">重置</button>
</div>
<!-- 工具栏 - 移动端垂直排列,桌面水平排列 -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex flex-wrap items-center gap-2">
<!-- 左侧按钮 -->
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- 右侧按钮 -->
</div>
</div>
<!-- 页面头部 - 移动端垂直,桌面水平 -->
<section class="page-header">
<!-- 自动响应式 -->
</section>
```
### 1.7 移动端适配要点
**输入框:**
- 字体大小必须 >= 16px,防止 iOS 自动缩放
- 增加触摸区域,padding 适当加大
**按钮:**
- 使用 text-sm 保持统一高度
- 全宽按钮在移动端使用 `w-full sm:w-auto`
**间距:**
- 移动端使用 gap-2 替代 gap-3/gap-4
- 卡片内边距使用 p-4 或 p-5
**表格:**
- 移动端使用卡片列表替代表格
- 使用 `hidden md:block` 和 `md:hidden` 切换显示
---
## 二、已改造页面清单
### 2.1 核心页面
| 文件路径 | 状态 | 主要改造内容 |
|----------|------|--------------|
| `src/layouts/MainLayout.vue` | ✅ 完成 | 深色侧边栏、导航菜单 |
| `src/views/Login.vue` | ✅ 完成 | 深色登录表单、玻璃态效果 |
| `src/views/dashboard/Dashboard.vue` | ✅ 完成 | 统计卡片、数据表格 |
| `src/views/equipment/EquipmentList.vue` | ✅ 完成 | 资产列表、筛选器、批量操作弹窗 |
| `src/views/borrow/BorrowList.vue` | ✅ 完成 | 借还记录表格、状态标签 |
| `src/views/borrow/BorrowScan.vue` | ✅ 完成 | 扫码借出界面 |
| `src/views/borrow/ReturnScan.vue` | ✅ 完成 | 扫码归还界面 |
| `src/views/statistics/Statistics.vue` | ✅ 完成 | 图表、统计卡片(含ECharts深色配置) |
### 2.2 设置页面
| 文件路径 | 状态 | 主要改造内容 |
|----------|------|--------------|
| `src/views/settings/Settings.vue` | ✅ 完成 | 设置主页布局 |
| `src/views/settings/components/SiteSettings.vue` | ✅ 完成 | 站点信息表单 |
| `src/views/settings/components/UserManagement.vue` | ✅ 完成 | 用户管理表格 |
| `src/views/settings/components/PersonManagement.vue` | ✅ 完成 | 使用者管理 |
| `src/views/settings/components/StorageLocationManagement.vue` | ✅ 完成 | 地址管理 |
| `src/views/settings/components/AssetTypeManagement.vue` | ✅ 完成 | 资产类型管理 |
| `src/views/settings/components/DepartmentManagement.vue` | ✅ 完成 | 部门树形表格 |
| `src/views/settings/components/MaintenanceManagement.vue` | ✅ 完成 | 维修记录 |
| `src/views/settings/components/DisposalManagement.vue` | ✅ 完成 | 报废管理 |
| `src/views/settings/components/OperationLogList.vue` | ✅ 完成 | 操作日志 |
### 2.3 全局组件
| 文件路径 | 状态 | 主要改造内容 |
|----------|------|--------------|
| `src/components/StatCard.vue` | ✅ 完成 | 统计卡片组件 |
| `src/components/GlobalSearch.vue` | ✅ 完成 | 全局搜索下拉框 |
| `src/components/BarcodePrint.vue` | ✅ 完成 | 二维码打印 |
| `src/components/MountHistory.vue` | ✅ 完成 | 挂载历史 |
| `src/components/MountManager.vue` | ✅ 完成 | 挂载管理 |
---
## 三、状态标签颜色规范
### 3.1 资产状态
```vue
<!-- 可用 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-apple-green/15 text-apple-green">可用</span>
<!-- 已借出 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-apple-orange/15 text-apple-orange">已借出</span>
<!-- 维护中 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-apple-red/15 text-apple-red">维护中</span>
<!-- 待报废 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-apple-yellow/15 text-apple-yellow">待报废</span>
<!-- 已报废 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-system-gray-6 text-system-label-tertiary">已报废</span>
```
### 3.2 启用/停用状态
```vue
<!-- 启用 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-apple-green/15 text-apple-green">启用</span>
<!-- 停用 -->
<span class="rounded-apple px-3 py-1 text-apple-footnote bg-system-gray-6 text-system-label-tertiary">停用</span>
```
### 3.3 父/子资产标签
```vue
<!-- 父资产 -->
<span class="rounded-apple px-2 py-0.5 text-apple-footnote bg-apple-blue/15 text-apple-blue">父</span>
<!-- 子资产 -->
<span class="rounded-apple px-2 py-0.5 text-apple-footnote bg-system-gray-6 text-system-label-secondary">子</span>
```
---
## 四、ECharts 深色主题配置
```javascript
// 图表背景透明
theme: 'dark',
backgroundColor: 'transparent',
// 文字颜色
textStyle: {
color: '#98989d'
},
// 坐标轴颜色
axisLine: {
lineStyle: { color: '#3a3a3c' }
},
axisLabel: {
color: '#98989d'
},
// 分割网格
splitLine: {
lineStyle: { color: '#2c2c2e' }
},
// 提示框
tooltip: {
backgroundColor: 'rgba(42, 42, 42, 0.95)',
borderColor: '#3a3a3c',
textStyle: { color: '#ffffff' }
}
```
---
## 五、弹窗/模态框规范
```vue
<!-- 弹窗遮罩 -->
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<!-- 弹窗内容 -->
<div class="bg-system-background-primary rounded-apple p-6 w-96 shadow-apple-xl">
<h3 class="text-apple-title text-system-label-primary mb-4">标题</h3>
<!-- 表单内容 -->
<div class="flex justify-end gap-3 mt-6">
<button class="btn-apple-secondary">取消</button>
<button class="btn-apple-primary">确认</button>
</div>
</div>
</div>
```
---
## 六、表格样式规范
```vue
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5 text-left text-apple-footnote text-system-label-tertiary">
<th class="py-3 px-4">列标题</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-system-gray-6 text-system-label-primary text-apple-body">
<td class="py-3 px-4">单元格内容</td>
</tr>
</tbody>
</table>
```
---
## 七、表单输入规范
```vue
<!-- 文本输入 -->
<div>
<label class="block text-apple-body text-system-label-primary mb-2">标签</label>
<input type="text" class="input-apple" placeholder="提示文字" />
</div>
<!-- 下拉选择 -->
<div>
<label class="block text-apple-body text-system-label-primary mb-2">标签</label>
<select class="input-apple">
<option value="">请选择</option>
</select>
</div>
<!-- 文本域 -->
<div>
<label class="block text-apple-body text-system-label-primary mb-2">标签</label>
<textarea class="input-apple" rows="3"></textarea>
</div>
```
---
## 八、页面头部规范
```vue
<section class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div>
<p class="text-apple-footnote text-system-label-secondary uppercase tracking-wider">英文标题</p>
<h2 class="text-apple-large-title text-system-label-primary mt-1">中文标题</h2>
<p class="text-apple-body text-system-label-secondary mt-2">副标题描述</p>
</div>
<!-- 统计卡片 -->
<div class="flex gap-4">
<div class="card-apple p-4 min-w-[120px]">
<p class="text-apple-footnote text-system-label-secondary">标签</p>
<p class="text-apple-title text-system-label-primary mt-1">数值</p>
</div>
</div>
</section>
```
---
## 九、待确认/待完善事项
1. **资产编辑页面** (`EquipmentEdit.vue`) - 需要检查是否已应用深色主题
2. **资产新增页面** - 需要检查是否已应用深色主题
3. **响应式适配** - 已在主要页面添加移动端适配,但可能需要进一步测试
4. **打印样式** - 二维码打印页面可能需要单独的打印媒体查询
---
## 十、快速参考:常用类名速查
### 布局
- `space-y-6` - 垂直间距
- `flex flex-wrap items-center justify-between gap-4` - 工具栏布局
- `grid grid-cols-1 md:grid-cols-3 gap-4` - 响应式网格
### 颜色
- `text-system-label-primary` - 主文字(白)
- `text-system-label-secondary` - 次级文字(灰)
- `text-system-label-tertiary` - 三级文字(深灰)
- `bg-system-background-primary` - 主背景
- `bg-system-gray-6` - 深色背景
### 组件
- `card-apple` - 卡片
- `input-apple` - 输入框
- `btn-apple-primary` - 主按钮
- `btn-apple-secondary` - 次按钮
- `rounded-apple` - 圆角
---
## 十一、样式系统规范化(2026-03-17更新)
### 11.1 问题发现
- 样式类命名混乱:`btn-primary` 与 `btn-apple-primary` 混用
- 按钮/输入框尺寸不统一
- 移动端适配不完善
### 11.2 规范化措施
**统一按钮尺寸:**
- 高度统一为 44px
- 圆角统一为 8px
- 所有按钮添加 `text-sm`
- 按钮间距统一为 `gap-2`
**统一输入框尺寸:**
- 高度统一为 44px
- 圆角统一为 8px
- 移动端字体大小 >= 16px 防止缩放
**响应式布局优化:**
- 筛选器:`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3`
- 工具栏:`flex-col lg:flex-row`
- 页面头部:自动响应式
### 11.3 已修复文件
| 文件 | 修复内容 |
|------|----------|
| style.css | 统一按钮/输入框尺寸,添加移动端适配 |
| EquipmentList.vue | 优化筛选器和工具栏布局 |
| BorrowList.vue | 优化筛选器和按钮布局 |
| BorrowScan.vue | 统一按钮尺寸 |
| ReturnScan.vue | 统一按钮尺寸 |
| Statistics.vue | 统一按钮尺寸 |
| Dashboard.vue | 统一按钮尺寸 |
---
## 十二、文件变更记录
| 日期 | 文件 | 变更类型 | 说明 |
|------|------|----------|------|
| 2026-03-16 | style.css | 修改 | 添加深色主题CSS变量 |
| 2026-03-16 | tailwind.config.js | 修改 | 配置深色主题颜色 |
| 2026-03-16 | MainLayout.vue | 重写 | 深色侧边栏 |
| 2026-03-16 | Login.vue | 重写 | 深色登录页 |
| 2026-03-16 | Dashboard.vue | 重写 | 深色仪表盘 |
| 2026-03-16 | EquipmentList.vue | 重写 | 深色资产列表 |
| 2026-03-16 | BorrowList.vue | 重写 | 深色借还记录 |
| 2026-03-16 | BorrowScan.vue | 重写 | 深色扫码借出 |
| 2026-03-16 | ReturnScan.vue | 重写 | 深色扫码归还 |
| 2026-03-16 | Statistics.vue | 重写 | 深色统计页 |
| 2026-03-16 | Settings.vue + components | 重写 | 深色设置页 |
| 2026-03-16 | GlobalSearch.vue | 修改 | 更新搜索下拉样式 |
| 2026-03-16 | BarcodePrint.vue | 新增 | 二维码打印组件 |
| 2026-03-17 | style.css | 修改 | 规范化样式系统 |
| 2026-03-17 | EquipmentList.vue | 修改 | 优化响应式布局 |
| 2026-03-17 | BorrowList.vue | 修改 | 优化响应式布局 |
| 2026-03-17 | BorrowScan.vue | 修改 | 统一按钮尺寸 |
| 2026-03-17 | ReturnScan.vue | 修改 | 统一按钮尺寸 |
| 2026-03-17 | Statistics.vue | 修改 | 统一按钮尺寸 |
| 2026-03-17 | Dashboard.vue | 修改 | 统一按钮尺寸 |
---
## 十三、样式系统使用规范
### 必须遵守的规则
1. **按钮类名**:统一使用 `btn-primary` 或 `btn-apple-primary`(两者等价)
2. **按钮尺寸**:必须添加 `text-sm` 保持高度一致
3. **按钮间距**:使用 `gap-2` 而非 `gap-3` 或 `gap-4`
4. **输入框类名**:统一使用 `input` 或 `input-apple`(两者等价)
5. **筛选器布局**:使用 `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3`
6. **工具栏布局**:使用 `flex-col lg:flex-row lg:items-center lg:justify-between`
7. **移动端按钮**:使用 `w-full sm:w-auto` 实现全宽到自适应的切换
### 禁止的做法
- ❌ 不要混用 `gap-2` 和 `gap-3`/`gap-4`
- ❌ 不要在按钮上使用不同的高度
- ❌ 不要在移动端使用小于 16px 的输入框字体
- ❌ 不要创建新的样式类名,优先使用现有规范
---
## 十四、后续Agent工作建议
1. **检查未规范化的页面**:搜索 `gap-3`、`gap-4` 在按钮组中的使用
2. **测试移动端体验**:在真实移动设备上测试所有页面
3. **检查编辑页面**:确认 `EquipmentEdit.vue` 等编辑页面是否已应用规范
4. **完善细节**:微调各页面的间距、对齐等细节
5. **添加主题切换**:如需支持亮色/深色切换,可添加主题切换功能
---
**文档版本**: v2.0
**创建日期**: 2026-03-16
**最后更新**: 2026-03-17
**改造范围**: 前端全部页面
**设计风格**: 深色极简风格(青绿色系强调色)

233
项目交接文档.md Normal file
View File

@ -0,0 +1,233 @@
# 固定资产管理系统 - 前端视觉改造交接文档
## 📋 项目概述
本次改造将原系统的视觉风格从暖色调转变为**深色极简主题**,统一了全站的样式规范,并移除了大量不必要的说明文字。
---
## 🎨 主题变更
### 颜色系统
#### 变更前(暖色调)
- 主背景:`#fbf3ea`(米黄色)
- 卡片背景:`#fffefc`(米白色)
- 强调色:`#b45f4d`(红褐色)
#### 变更后(深色主题)
```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 /* 边框 */
--accent-primary: #10a37f /* 强调色 - 青绿 */
```
---
## 📁 修改文件清单
### 1. 核心样式文件
| 文件 | 修改内容 |
|------|----------|
| `frontend/src/style.css` | 新增深色主题 CSS 变量和组件类 |
| `frontend/tailwind.config.js` | 配置深色主题颜色映射 |
### 2. 布局组件
| 文件 | 修改内容 |
|------|----------|
| `frontend/src/components/MainLayout.vue` | 深色主题适配,侧边栏和导航栏样式 |
| `frontend/src/components/GlobalSearch.vue` | 搜索组件深色主题 |
| `frontend/src/components/StatCard.vue` | 统计卡片深色主题 |
### 3. 页面文件
#### 核心页面
- `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/settings/Settings.vue` - 设置首页深色主题 + 移除说明文字
- `frontend/src/views/settings/components/SiteSettings.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/PersonManagement.vue` - 使用者管理深色主题 + 移除说明文字
- `frontend/src/views/settings/components/UserManagement.vue` - 用户管理深色主题 + 移除说明文字
- `frontend/src/views/settings/components/HrSyncManagement.vue` - 组织同步深色主题 + 移除说明文字
- `frontend/src/views/settings/components/MaintenanceManagement.vue` - 维修管理深色主题 + 移除说明文字
- `frontend/src/views/settings/components/DisposalManagement.vue` - 报废管理深色主题 + 移除说明文字
- `frontend/src/views/settings/components/OperationLogList.vue` - 操作日志深色主题 + 移除说明文字
### 4. 功能组件
| 文件 | 修改内容 |
|------|----------|
| `frontend/src/components/BarcodePrint.vue` | 二维码打印样式重新设计(横向60x40mm标签) |
| `frontend/src/components/CameraScannerModal.vue` | 扫码弹窗扩大摄像头区域 + 精简说明 |
---
## 🔧 样式标准化工作
### 1. 统一类名替换
将分散的 Apple 风格类名统一为标准类名:
| 旧类名(已废弃) | 新类名(标准) |
|------------------|----------------|
| `btn-apple-primary` | `btn-primary` |
| `btn-apple-secondary` | `btn-secondary` |
| `input-apple` | `input` |
| `card-apple` | `card` |
| `text-apple-footnote` | `text-xs` |
| `text-apple-body` | `text-sm` |
| `text-apple-title` | `text-xl font-semibold` |
| `text-apple-large-title` | `text-2xl font-semibold` |
| `text-system-label-primary` | `text-text-primary` |
| `text-system-label-secondary` | `text-text-secondary` |
| `text-system-label-tertiary` | `text-text-tertiary` |
| `bg-system-gray-6` | `bg-bg-elevated` |
| `border-system-gray-5` | `border-border` |
| `rounded-apple` | `rounded-lg` |
### 2. 移除说明文字
移除了以下文件中的说明性文字:
- `surface-note` 提示框
- `page-subtitle` 页面副标题
- `page-meta__subvalue` 统计卡片副标题
- `form-panel__desc` 表单描述
- 各处的帮助提示文字
---
## 📝 新增规范文档
### 1. 视觉规范文档
**文件:** `前端视觉规范.md`
包含内容:
- 设计原则
- 颜色系统详细定义
- 组件规范(按钮、输入框、卡片等)
- 布局规范
- 字体规范
- 快速参考表
- 示例代码模板
### 2. 深色主题总结(早期版本)
**文件:** `深色主题改造总结.md`
---
## 🎯 关键设计决策
### 1. 二维码打印标签
- **尺寸**:60mm x 40mm 横向
- **DPI**:300
- **颜色**:纯黑白(无灰色)
- **内容**:公司名称、资产名称、资产编码、资产归属、大二维码
- **字体**:加粗,大号以确保打印清晰
### 2. 扫码界面
- 摄像头区域扩大到 50-60vh
- 精简了所有说明文字
- 只保留标题、摄像头、操作按钮、手动输入
### 3. 移动端适配
- 卡片内边距减小到 12px
- 圆角减小到 8px
- 表格转为卡片列表
- 按钮在移动端全宽显示
---
## ⚠️ 注意事项
### 1. 禁止使用的类名
以下类名已废弃,不要再使用:
```
btn-apple-primary, btn-apple-secondary
input-apple, card-apple
text-apple-*, bg-apple-*
text-system-label-*, bg-system-gray-*, border-system-gray-*
rounded-apple
surface-note, page-subtitle, section-subtitle
```
### 2. 推荐使用的类名
```
/* 按钮 */
btn-primary, btn-secondary, btn-icon
/* 输入 */
input, select
/* 卡片 */
card, panel
/* 颜色 */
text-text-primary, text-text-secondary, text-text-tertiary
bg-bg-primary, bg-bg-secondary, bg-bg-elevated
border-border
/* 状态 */
badge-success, badge-warning, badge-error
text-status-success, text-status-warning, text-status-error
```
### 3. 间距规范
- 大区块间距:`space-y-6`(24px)
- 卡片内间距:`gap-4`(16px)
- 按钮组间距:`gap-3`(12px)
---
## 🔍 验证检查清单
在继续开发前,请确认:
- [ ] 所有页面使用统一的深色主题
- [ ] 没有使用已废弃的类名
- [ ] 按钮使用 `btn-primary` / `btn-secondary`
- [ ] 输入框使用 `input` / `select`
- [ ] 卡片使用 `card` / `panel`
- [ ] 颜色使用 `text-text-*` / `bg-bg-*`
- [ ] 没有多余的说明文字
- [ ] 移动端样式已测试
---
## 📞 后续开发建议
1. **保持风格一致**:所有新页面必须遵循 `前端视觉规范.md`
2. **复用现有组件**:优先使用已定义的组件类(card、btn-primary等)
3. **避免重复造轮子**:参考已有页面的实现方式
4. **移动端优先**:新功能必须考虑移动端适配
---
**交接日期:** 2026-03-17
**交接人:** Claude
**接收人:** 下一个 Agent