完成本轮功能与界面改造,备份可运行版本
This commit is contained in:
parent
6d371f347b
commit
c3f8eb6f6f
@ -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
2
.gitignore
vendored
@ -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.*
|
||||
|
||||
75
AGENTS.md
75
AGENTS.md
@ -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
8
backend/.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
uploads
|
||||
coverage
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
@ -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`);
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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++
|
||||
|
||||
@ -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++
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) }
|
||||
})
|
||||
|
||||
@ -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, '已报废')
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
|
||||
@ -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, '自定义录入模式不需要生成编码')
|
||||
|
||||
@ -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 || ''
|
||||
|
||||
@ -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, '可用')
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
|
||||
@ -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)
|
||||
},
|
||||
'获取成功'
|
||||
|
||||
167
backend/src/controllers/notificationController.ts
Normal file
167
backend/src/controllers/notificationController.ts
Normal 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 || '全部标记已读失败')
|
||||
}
|
||||
}
|
||||
@ -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 || '获取使用者选项失败')
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}, '获取成功')
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
19
backend/src/routes/notifications.ts
Normal file
19
backend/src/routes/notifications.ts
Normal 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
|
||||
@ -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
238
backend/src/utils/assetValue.ts
Normal file
238
backend/src/utils/assetValue.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
1
certbot/www/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -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 ">>> 等待服务启动..."
|
||||
|
||||
@ -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
7
frontend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
@ -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";
|
||||
|
||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
9
frontend/public/branding/favicon.svg
Normal file
9
frontend/public/branding/favicon.svg
Normal 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 |
10
frontend/public/branding/logo.svg
Normal file
10
frontend/public/branding/logo.svg
Normal 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 |
BIN
frontend/public/branding/未标题-1.jpg
Normal file
BIN
frontend/public/branding/未标题-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
30
frontend/src/api/notifications.ts
Normal file
30
frontend/src/api/notifications.ts
Normal 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
|
||||
}
|
||||
@ -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
44
frontend/src/api/sync.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
376
frontend/src/components/CameraScannerModal.vue
Normal file
376
frontend/src/components/CameraScannerModal.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
22
frontend/src/components/InfoHint.vue
Normal file
22
frontend/src/components/InfoHint.vue
Normal 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>
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
30
frontend/src/stores/notifications.ts
Normal file
30
frontend/src/stores/notifications.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
系统设置 > 使用者信息
|
||||
</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(() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
316
frontend/src/views/notifications/NotificationCenter.vue
Normal file
316
frontend/src/views/notifications/NotificationCenter.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
893
frontend/src/views/settings/components/HrSyncManagement.vue
Normal file
893
frontend/src/views/settings/components/HrSyncManagement.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
]
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -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
466
前端视觉规范.md
Normal 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
856
前端视觉重构台账.md
Normal 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
527
深色主题改造总结.md
Normal 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
233
项目交接文档.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user