Compare commits
12 Commits
9a1485661d
...
9075be6046
Author | SHA1 | Date |
---|---|---|
|
9075be6046 | |
|
1a26202e34 | |
|
5abb19f467 | |
|
754900d0b8 | |
|
c6b59483de | |
|
7aac442e9a | |
|
ee320de2ca | |
|
652883d142 | |
|
dd513444c5 | |
|
0efb7787a7 | |
|
12dbc91830 | |
|
dd76e90a26 |
|
@ -21,20 +21,22 @@ module.exports = {
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
// 允许使用短路表达式
|
||||||
|
'no-unused-expressions': 'off',
|
||||||
|
// 允许使用 let 声明后不重新赋值的变量
|
||||||
|
'prefer-const': 'off',
|
||||||
// 允许使用 any 类型
|
// 允许使用 any 类型
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
|
||||||
// 允许声明但未使用的变量
|
// 允许声明但未使用的变量
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
vars: 'all', // 检查所有变量
|
vars: 'all',
|
||||||
args: 'none', // 不检查函数参数
|
args: 'none',
|
||||||
ignoreRestSiblings: true,
|
ignoreRestSiblings: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// 可选:关闭未定义变量检查
|
||||||
// 禁止使用未声明的变量
|
'no-undef': 'off',
|
||||||
'no-undef': 'error',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -29,7 +29,6 @@ export class DepartmentService extends BaseTreeService<Prisma.DepartmentDelegate
|
||||||
|
|
||||||
// 如果未找到部门,返回空数组
|
// 如果未找到部门,返回空数组
|
||||||
if (!ancestorDepartment) return [];
|
if (!ancestorDepartment) return [];
|
||||||
|
|
||||||
// 查询同域下以指定部门为祖先的部门血缘关系
|
// 查询同域下以指定部门为祖先的部门血缘关系
|
||||||
const departmentAncestries = await db.deptAncestry.findMany({
|
const departmentAncestries = await db.deptAncestry.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -335,4 +334,214 @@ export class DepartmentService extends BaseTreeService<Prisma.DepartmentDelegate
|
||||||
// 合并并去重父级和自身节点,返回唯一项
|
// 合并并去重父级和自身节点,返回唯一项
|
||||||
return getUniqueItems([...parents, ...selfItems], 'id');
|
return getUniqueItems([...parents, ...selfItems], 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门统计信息的缓存键
|
||||||
|
* @param options 可选参数
|
||||||
|
* @returns 缓存键字符串
|
||||||
|
*/
|
||||||
|
private getDeptStatsCacheKey(options?: any): string {
|
||||||
|
return `dept_stats_${JSON.stringify(options || {})}_${Date.now()
|
||||||
|
.toString()
|
||||||
|
.slice(0, -4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门统计数据,包含缓存策略
|
||||||
|
* 该方法优化了性能,通过缓存减少重复计算
|
||||||
|
* @returns 部门统计数据
|
||||||
|
*/
|
||||||
|
async getDepartmentStats() {
|
||||||
|
const cacheKey = this.getDeptStatsCacheKey();
|
||||||
|
const [departments, deptWithStaffCount] = await Promise.all([
|
||||||
|
db.department.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: { id: true, name: true, parentId: true, isDomain: true },
|
||||||
|
}),
|
||||||
|
db.department.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
_count: {
|
||||||
|
select: { deptStaffs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 处理数据
|
||||||
|
const stats = {
|
||||||
|
totalDepartments: departments.length,
|
||||||
|
departmentsWithStaff: deptWithStaffCount.filter(
|
||||||
|
(d) => d._count.deptStaffs > 0,
|
||||||
|
).length,
|
||||||
|
topDepartments: deptWithStaffCount
|
||||||
|
.sort((a, b) => b._count.deptStaffs - a._count.deptStaffs)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
staffCount: d._count.deptStaffs,
|
||||||
|
})),
|
||||||
|
domains: departments.filter((d) => d.isDomain).length,
|
||||||
|
departmentsByParent: this.groupDepartmentsByParent(departments),
|
||||||
|
};
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupDepartmentsByParent(departments: any[]): Record<string, any[]> {
|
||||||
|
const result: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
departments.forEach((dept) => {
|
||||||
|
const parentId = dept.parentId || 'root';
|
||||||
|
if (!result[parentId]) {
|
||||||
|
result[parentId] = [];
|
||||||
|
}
|
||||||
|
result[parentId].push(dept);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优化的部门查询方法,支持分页和缓存
|
||||||
|
* @param options 查询选项
|
||||||
|
* @returns 分页的部门数据
|
||||||
|
*/
|
||||||
|
async findManyOptimized(options: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
orderBy?: any;
|
||||||
|
filter?: any;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
orderBy = { order: 'asc' },
|
||||||
|
filter = {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const where = {
|
||||||
|
deletedAt: null,
|
||||||
|
...filter,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 并行执行总数查询和分页数据查询
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
db.department.count({ where }),
|
||||||
|
db.department.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
parentId: true,
|
||||||
|
isDomain: true,
|
||||||
|
order: true,
|
||||||
|
domainId: true,
|
||||||
|
_count: {
|
||||||
|
select: { deptStaffs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 格式化结果
|
||||||
|
const formattedItems = items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
parentId: item.parentId,
|
||||||
|
isDomain: item.isDomain,
|
||||||
|
order: item.order,
|
||||||
|
domainId: item.domainId,
|
||||||
|
staffCount: item._count.deptStaffs,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: formattedItems,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门树形结构,支持懒加载
|
||||||
|
* @param rootId 根节点ID
|
||||||
|
* @param lazy 是否懒加载
|
||||||
|
* @returns 树形结构数据
|
||||||
|
*/
|
||||||
|
async getDepartmentTree(rootId: string | null = null, lazy: boolean = false) {
|
||||||
|
// 基础查询条件
|
||||||
|
const baseWhere: any = { deletedAt: null };
|
||||||
|
|
||||||
|
// 如果是懒加载模式,只查询根节点的直接子节点
|
||||||
|
if (lazy && rootId) {
|
||||||
|
baseWhere.parentId = rootId;
|
||||||
|
}
|
||||||
|
// 如果不是懒加载且指定了rootId,查询该节点及其所有子孙节点
|
||||||
|
else if (rootId) {
|
||||||
|
const descendantIds = await this.getDescendantIds([rootId], true);
|
||||||
|
baseWhere.id = { in: descendantIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询部门数据
|
||||||
|
const departments = await db.department.findMany({
|
||||||
|
where: baseWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
parentId: true,
|
||||||
|
isDomain: true,
|
||||||
|
order: true,
|
||||||
|
_count: {
|
||||||
|
select: { children: true, deptStaffs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果是懒加载模式,返回扁平结构
|
||||||
|
if (lazy) {
|
||||||
|
return departments.map((dept) => ({
|
||||||
|
id: dept.id,
|
||||||
|
name: dept.name,
|
||||||
|
parentId: dept.parentId,
|
||||||
|
isDomain: dept.isDomain,
|
||||||
|
hasChildren: dept._count.children > 0,
|
||||||
|
staffCount: dept._count.deptStaffs,
|
||||||
|
isLeaf: dept._count.children === 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是懒加载模式,构建完整树形结构
|
||||||
|
return this.buildDepartmentTree(departments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建部门树形结构
|
||||||
|
* @param departments 部门列表
|
||||||
|
* @param parentId 父部门ID
|
||||||
|
* @returns 树形结构
|
||||||
|
*/
|
||||||
|
private buildDepartmentTree(
|
||||||
|
departments: any[],
|
||||||
|
parentId: string | null = null,
|
||||||
|
) {
|
||||||
|
return departments
|
||||||
|
.filter((dept) => dept.parentId === parentId)
|
||||||
|
.map((dept) => ({
|
||||||
|
id: dept.id,
|
||||||
|
name: dept.name,
|
||||||
|
isDomain: dept.isDomain,
|
||||||
|
staffCount: dept._count.deptStaffs,
|
||||||
|
children: this.buildDepartmentTree(departments, dept.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,11 @@ import EventBus, { CrudOperation } from "@server/utils/event-bus";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemLogService extends BaseService<Prisma.SystemLogDelegate> {
|
export class SystemLogService extends BaseService<Prisma.SystemLogDelegate> {
|
||||||
|
protected readonly prismaClient: any;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(db, ObjectType.SYSTEM_LOG, false); // 不自动处理更新时间和删除时间
|
super(db, ObjectType.SYSTEM_LOG, false);
|
||||||
|
this.prismaClient = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(args: Prisma.SystemLogCreateArgs) {
|
async create(args: Prisma.SystemLogCreateArgs) {
|
||||||
|
@ -31,20 +34,19 @@ export class SystemLogService extends BaseService<Prisma.SystemLogDelegate> {
|
||||||
return super.findMany(args); // 放弃分页结构
|
return super.findMany(args); // 放弃分页结构
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加分页查询方法
|
|
||||||
async findManyWithPagination({ page = 1, pageSize = 20, where = {}, ...rest }: any) {
|
async findManyWithPagination({ page = 1, pageSize = 20, where = {}, ...rest }: any) {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.delegate.findMany({
|
this.prismaClient.systemLog.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
...rest
|
...rest
|
||||||
}),
|
}),
|
||||||
this.delegate.count({ where })
|
this.prismaClient.systemLog.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -119,7 +121,6 @@ export class SystemLogService extends BaseService<Prisma.SystemLogDelegate> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { changes };
|
return { changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,12 +11,15 @@ export class ShareCodeService {
|
||||||
// 生成8位分享码,使用易读的字符
|
// 生成8位分享码,使用易读的字符
|
||||||
private readonly generateCode = customAlphabet(
|
private readonly generateCode = customAlphabet(
|
||||||
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
|
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||||
8
|
8,
|
||||||
);
|
);
|
||||||
|
|
||||||
constructor(private readonly resourceService: ResourceService) {}
|
constructor(private readonly resourceService: ResourceService) {}
|
||||||
|
|
||||||
async generateShareCode(fileId: string, fileName?: string): Promise<GenerateShareCodeResponse> {
|
async generateShareCode(
|
||||||
|
fileId: string,
|
||||||
|
fileName?: string,
|
||||||
|
): Promise<GenerateShareCodeResponse> {
|
||||||
try {
|
try {
|
||||||
// 检查文件是否存在
|
// 检查文件是否存在
|
||||||
const resource = await this.resourceService.findUnique({
|
const resource = await this.resourceService.findUnique({
|
||||||
|
@ -46,9 +49,7 @@ export class ShareCodeService {
|
||||||
expiresAt,
|
expiresAt,
|
||||||
isUsed: false,
|
isUsed: false,
|
||||||
// 只在没有现有文件名且提供了新文件名时才更新文件名
|
// 只在没有现有文件名且提供了新文件名时才更新文件名
|
||||||
...(fileName && !existingShareCode.fileName
|
...(fileName && !existingShareCode.fileName ? { fileName } : {}),
|
||||||
? { fileName }
|
|
||||||
: {})
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
@ -95,10 +96,10 @@ export class ShareCodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记分享码为已使用
|
// 标记分享码为已使用
|
||||||
await db.shareCode.update({
|
// await db.shareCode.update({
|
||||||
where: { id: shareCode.id },
|
// where: { id: shareCode.id },
|
||||||
data: { isUsed: true },
|
// data: { isUsed: true },
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 记录使用日志
|
// 记录使用日志
|
||||||
this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`);
|
this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`);
|
||||||
|
@ -117,10 +118,7 @@ export class ShareCodeService {
|
||||||
try {
|
try {
|
||||||
const result = await db.shareCode.deleteMany({
|
const result = await db.shareCode.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [{ expiresAt: { lt: new Date() } }, { isUsed: true }],
|
||||||
{ expiresAt: { lt: new Date() } },
|
|
||||||
{ isUsed: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,39 +5,47 @@ import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react-refresh/only-export-components": [
|
"react-refresh/only-export-components": [
|
||||||
"warn",
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
// 允许使用 any 类型
|
"@typescript-eslint/interface-name-prefix": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
// 允许使用 any 类型
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
// 允许使用 let 声明后不重新赋值的变量
|
||||||
|
"no-unused-expressions": "off",
|
||||||
|
// 允许使用 let 声明后不重新赋值的变量
|
||||||
|
"prefer-const": "off",
|
||||||
|
|
||||||
// 允许声明但未使用的变量
|
// 允许声明但未使用的变量
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
vars: "all", // 检查所有变量
|
vars: "all", // 检查所有变量
|
||||||
args: "none", // 不检查函数参数
|
args: "none", // 不检查函数参数
|
||||||
ignoreRestSiblings: true,
|
ignoreRestSiblings: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// 禁止使用未声明的变量
|
// 禁止使用未声明的变量
|
||||||
"no-undef": "error",
|
"no-undef": "error",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -15,8 +15,8 @@ export const defaultFields = [
|
||||||
label: '性别',
|
label: '性别',
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
options: [
|
options: [
|
||||||
{ label: '男', value: 'male' },
|
{ label: '男', value: '男' },
|
||||||
{ label: '女', value: 'female' },
|
{ label: '女', value: '女' },
|
||||||
],
|
],
|
||||||
group: '基本信息',
|
group: '基本信息',
|
||||||
order: 7 },
|
order: 7 },
|
||||||
|
@ -57,11 +57,7 @@ export const defaultFields = [
|
||||||
// 职务信息组
|
// 职务信息组
|
||||||
{ name: 'rank',
|
{ name: 'rank',
|
||||||
label: '衔职级别',
|
label: '衔职级别',
|
||||||
type: 'select',
|
type: 'text',
|
||||||
options: [
|
|
||||||
{ label: '', value: '' },
|
|
||||||
{ label: '', value: '' },
|
|
||||||
],
|
|
||||||
group: '职务信息',
|
group: '职务信息',
|
||||||
order: 13 },
|
order: 13 },
|
||||||
{ name: 'rankDate', label: '衔职时间', type: 'date', group: '职务信息', order: 14 },
|
{ name: 'rankDate', label: '衔职时间', type: 'date', group: '职务信息', order: 14 },
|
||||||
|
|
|
@ -3,302 +3,392 @@ import { ShareCodeGenerator } from "../sharecode/sharecodegenerator";
|
||||||
import { ShareCodeValidator } from "../sharecode/sharecodevalidator";
|
import { ShareCodeValidator } from "../sharecode/sharecodevalidator";
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { message, Progress, Button, Tabs, DatePicker } from "antd";
|
import { message, Progress, Button, Tabs, DatePicker } from "antd";
|
||||||
import { UploadOutlined, DeleteOutlined, InboxOutlined } from "@ant-design/icons";
|
import {
|
||||||
import {env} from '../../../../env'
|
UploadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { env } from "../../../../env";
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
export default function DeptSettingPage() {
|
export default function DeptSettingPage() {
|
||||||
const [uploadedFileId, setUploadedFileId] = useState<string>('');
|
const [uploadedFileId, setUploadedFileId] = useState<string>("");
|
||||||
const [uploadedFileName, setUploadedFileName] = useState<string>('');
|
const [uploadedFileName, setUploadedFileName] = useState<string>("");
|
||||||
const [fileNameMap, setFileNameMap] = useState<Record<string, string>>({});
|
const [fileNameMap, setFileNameMap] = useState<Record<string, string>>({});
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<{id: string, name: string}[]>([]);
|
const [uploadedFiles, setUploadedFiles] = useState<
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
{ id: string; name: string }[]
|
||||||
const [expireTime, setExpireTime] = useState<Date | null>(null);
|
>([]);
|
||||||
const dropRef = useRef<HTMLDivElement>(null);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [expireTime, setExpireTime] = useState<Date | null>(null);
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 使用您的 useTusUpload hook
|
// 使用您的 useTusUpload hook
|
||||||
const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload({
|
const { uploadProgress, isUploading, uploadError, handleFileUpload } =
|
||||||
onSuccess: (result) => {
|
useTusUpload({
|
||||||
setUploadedFileId(result.fileId);
|
onSuccess: (result) => {
|
||||||
setUploadedFileName(result.fileName);
|
setUploadedFileId(result.fileId);
|
||||||
message.success('文件上传成功');
|
setUploadedFileName(result.fileName);
|
||||||
},
|
message.success("文件上传成功");
|
||||||
onError: (error: Error) => {
|
},
|
||||||
message.error('上传失败:' + error.message);
|
onError: (error: Error) => {
|
||||||
}
|
message.error("上传失败:" + error.message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理文件上传
|
// 清除已上传文件
|
||||||
const handleFileSelect = async (file: File) => {
|
const handleClearFile = () => {
|
||||||
const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识
|
setUploadedFileId("");
|
||||||
|
setUploadedFileName("");
|
||||||
|
setUploadedFiles([]);
|
||||||
|
setFileNameMap({});
|
||||||
|
};
|
||||||
|
|
||||||
handleFileUpload(
|
// 处理文件上传
|
||||||
file,
|
const handleFileSelect = async (file: File) => {
|
||||||
async (result) => {
|
// 限制:如果已有上传文件,则提示用户
|
||||||
setUploadedFileId(result.fileId);
|
if (uploadedFiles.length > 0) {
|
||||||
setUploadedFileName(result.fileName);
|
message.warning("只能上传一个文件,请先删除已上传的文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识
|
||||||
|
|
||||||
// 添加到已上传文件列表
|
handleFileUpload(
|
||||||
setUploadedFiles(prev => [...prev, {id: result.fileId, name: file.name}]);
|
file,
|
||||||
|
async (result) => {
|
||||||
|
setUploadedFileId(result.fileId);
|
||||||
|
setUploadedFileName(result.fileName);
|
||||||
|
|
||||||
// 在前端保存文件名映射(用于当前会话)
|
// 添加到已上传文件列表
|
||||||
setFileNameMap(prev => ({
|
setUploadedFiles([{ id: result.fileId, name: file.name }]);
|
||||||
...prev,
|
|
||||||
[result.fileId]: file.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 上传成功后保存原始文件名到数据库
|
// 在前端保存文件名映射(用于当前会话)
|
||||||
try {
|
setFileNameMap({
|
||||||
console.log('正在保存文件名到数据库:', result.fileName, '对应文件ID:', result.fileId);
|
[result.fileId]: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/filename`, {
|
// 上传成功后保存原始文件名到数据库
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileId: result.fileId,
|
|
||||||
fileName: file.name
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
|
||||||
console.log('保存文件名响应:', response.status, responseText);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('保存文件名失败:', responseText);
|
|
||||||
message.warning('文件名保存失败,下载时可能无法显示原始文件名');
|
|
||||||
} else {
|
|
||||||
console.log('文件名保存成功:', file.name);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存文件名请求失败:', error);
|
|
||||||
message.warning('文件名保存失败,下载时可能无法显示原始文件名');
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success('文件上传成功');
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
message.error('上传失败:' + error.message);
|
|
||||||
},
|
|
||||||
fileKey
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理多个文件上传
|
|
||||||
// const handleFilesUpload = (file: File) => {
|
|
||||||
// handleFileSelect(file);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// 拖拽相关处理函数
|
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(false);
|
|
||||||
|
|
||||||
handleFileSelect(e.dataTransfer.files[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
// 处理分享码生成成功
|
|
||||||
const handleShareSuccess = (code: string) => {
|
|
||||||
message.success('分享码生成成功:' + code);
|
|
||||||
// 可以在这里添加其他逻辑,比如保存到历史记录
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理分享码验证成功
|
|
||||||
const handleValidSuccess = async (fileId: string, fileName: string) => {
|
|
||||||
try {
|
try {
|
||||||
// 构建下载URL(包含文件名参数)
|
console.log(
|
||||||
const downloadUrl = `/upload/download/${fileId}?fileName=${encodeURIComponent(fileName)}`;
|
"正在保存文件名到数据库:",
|
||||||
const response = await fetch(downloadUrl);
|
result.fileName,
|
||||||
if (!response.ok) {
|
"对应文件ID:",
|
||||||
throw new Error('文件下载失败');
|
result.fileId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/filename`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileId: result.fileId,
|
||||||
|
fileName: file.name,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 创建下载链接
|
const responseText = await response.text();
|
||||||
const blob = await response.blob();
|
console.log("保存文件名响应:", response.status, responseText);
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
|
|
||||||
// 直接使用传入的 fileName
|
if (!response.ok) {
|
||||||
link.download = fileName;
|
console.error("保存文件名失败:", responseText);
|
||||||
|
message.warning("文件名保存失败,下载时可能无法显示原始文件名");
|
||||||
// 触发下载
|
} else {
|
||||||
document.body.appendChild(link);
|
console.log("文件名保存成功:", file.name);
|
||||||
link.click();
|
}
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
message.success('文件下载开始');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载失败:', error);
|
console.error("保存文件名请求失败:", error);
|
||||||
message.error('文件下载失败');
|
message.warning("文件名保存失败,下载时可能无法显示原始文件名");
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
message.success("文件上传成功");
|
||||||
return (
|
},
|
||||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
|
(error) => {
|
||||||
<h2>文件分享中心</h2>
|
message.error("上传失败:" + error.message);
|
||||||
|
},
|
||||||
<Tabs defaultActiveKey="upload">
|
fileKey
|
||||||
<TabPane tab="上传分享" key="upload">
|
|
||||||
{/* 文件上传区域 */}
|
|
||||||
<div style={{ marginBottom: '40px' }}>
|
|
||||||
<h3>第一步:上传文件</h3>
|
|
||||||
<div
|
|
||||||
ref={dropRef}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
style={{
|
|
||||||
padding: '20px',
|
|
||||||
border: `2px dashed ${isDragging ? '#1890ff' : '#d9d9d9'}`,
|
|
||||||
borderRadius: '8px',
|
|
||||||
textAlign: 'center',
|
|
||||||
backgroundColor: isDragging ? 'rgba(24, 144, 255, 0.05)' : 'transparent',
|
|
||||||
transition: 'all 0.3s',
|
|
||||||
marginBottom: '20px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InboxOutlined style={{ fontSize: '48px', color: isDragging ? '#1890ff' : '#d9d9d9' }} />
|
|
||||||
<p>点击或拖拽文件到此区域进行上传</p>
|
|
||||||
<p style={{ fontSize: '12px', color: '#888' }}>支持单个上传文件</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="file-input"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
handleFileSelect(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isUploading}
|
|
||||||
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="file-input"
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: '#1890ff',
|
|
||||||
color: 'white',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginTop: '10px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadOutlined /> 选择文件
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 已上传文件列表 */}
|
|
||||||
{uploadedFiles.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{uploadedFiles.map((file, index) => (
|
|
||||||
<div key={file.id} style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '10px 15px',
|
|
||||||
borderBottom: index < uploadedFiles.length - 1 ? '1px solid #f0f0f0' : 'none',
|
|
||||||
backgroundColor: index % 2 === 0 ? '#fafafa' : 'white'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#52c41a',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: '10px'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'white', fontSize: '12px' }}>✓</span>
|
|
||||||
</div>
|
|
||||||
<span>{file.name}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<DeleteOutlined style={{ color: '#ff4d4f' }} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUploading && (
|
|
||||||
<div style={{ marginTop: '20px' }}>
|
|
||||||
<Progress
|
|
||||||
percent={Math.round(Object.values(uploadProgress)[0] || 0)}
|
|
||||||
status="active"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploadError && (
|
|
||||||
<div style={{ color: '#ff4d4f', marginTop: '10px' }}>
|
|
||||||
{uploadError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 生成分享码区域 */}
|
|
||||||
{uploadedFileId && (
|
|
||||||
<div style={{ marginBottom: '40px' }}>
|
|
||||||
<h3>第二步:生成分享码</h3>
|
|
||||||
|
|
||||||
<ShareCodeGenerator
|
|
||||||
fileId={uploadedFileId}
|
|
||||||
onSuccess={handleShareSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabPane>
|
|
||||||
|
|
||||||
{/* 使用分享码区域 */}
|
|
||||||
<TabPane tab="下载文件" key="download">
|
|
||||||
<div>
|
|
||||||
<h3>使用分享码下载文件</h3>
|
|
||||||
<ShareCodeValidator
|
|
||||||
onValidSuccess={handleValidSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理多个文件上传 - 已移除
|
||||||
|
// const handleFilesUpload = (file: File) => {
|
||||||
|
// handleFileSelect(file);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// 处理文件删除
|
||||||
|
const handleDeleteFile = async (fileId: string) => {
|
||||||
|
try {
|
||||||
|
// 可以添加删除文件的API调用
|
||||||
|
// const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/delete/${fileId}`, {
|
||||||
|
// method: 'DELETE'
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!response.ok) {
|
||||||
|
// throw new Error('删除文件失败');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 无论服务器删除是否成功,前端都需要更新状态
|
||||||
|
setUploadedFiles([]);
|
||||||
|
setUploadedFileId("");
|
||||||
|
setUploadedFileName("");
|
||||||
|
setFileNameMap({});
|
||||||
|
|
||||||
|
message.success("文件已删除");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("删除文件错误:", error);
|
||||||
|
message.error("删除文件失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖拽相关处理函数
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
handleFileSelect(e.dataTransfer.files[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理分享码生成成功
|
||||||
|
const handleShareSuccess = (code: string) => {
|
||||||
|
message.success("分享码生成成功:" + code);
|
||||||
|
// 可以在这里添加其他逻辑,比如保存到历史记录
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理分享码验证成功
|
||||||
|
const handleValidSuccess = async (fileId: string, fileName: string) => {
|
||||||
|
try {
|
||||||
|
// 构建下载URL(包含文件名参数)
|
||||||
|
const downloadUrl = `/upload/download/${fileId}?fileName=${encodeURIComponent(
|
||||||
|
fileName
|
||||||
|
)}`;
|
||||||
|
const response = await fetch(downloadUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("文件下载失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
|
||||||
|
// 直接使用传入的 fileName
|
||||||
|
link.download = fileName;
|
||||||
|
|
||||||
|
// 触发下载
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success("文件下载开始");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("下载失败:", error);
|
||||||
|
message.error("文件下载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
|
||||||
|
<h2>文件分享中心</h2>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="upload">
|
||||||
|
<TabPane tab="上传分享" key="upload">
|
||||||
|
{/* 文件上传区域 */}
|
||||||
|
<div style={{ marginBottom: "40px" }}>
|
||||||
|
<h3>第一步:上传文件</h3>
|
||||||
|
|
||||||
|
{/* 如果没有已上传文件,显示上传区域 */}
|
||||||
|
{uploadedFiles.length === 0 ? (
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
style={{
|
||||||
|
padding: "20px",
|
||||||
|
border: `2px dashed ${isDragging ? "#1890ff" : "#d9d9d9"}`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
textAlign: "center",
|
||||||
|
backgroundColor: isDragging
|
||||||
|
? "rgba(24, 144, 255, 0.05)"
|
||||||
|
: "transparent",
|
||||||
|
transition: "all 0.3s",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InboxOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: "48px",
|
||||||
|
color: isDragging ? "#1890ff" : "#d9d9d9",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p>点击或拖拽文件到此区域进行上传</p>
|
||||||
|
<p style={{ fontSize: "12px", color: "#888" }}>
|
||||||
|
只能上传单个文件
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-input"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-input"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginTop: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadOutlined /> 选择文件
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#f6ffed",
|
||||||
|
border: "1px solid #b7eb8f",
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "#52c41a", margin: 0 }}>
|
||||||
|
您已上传文件,请继续下一步生成分享码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已上传文件列表 */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #f0f0f0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadedFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "10px 15px",
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#52c41a",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginRight: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "white", fontSize: "12px" }}>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined style={{ color: "#ff4d4f" }} />}
|
||||||
|
onClick={() => handleDeleteFile(file.id)}
|
||||||
|
title="删除此文件"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div style={{ marginTop: "20px" }}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(Object.values(uploadProgress)[0] || 0)}
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<div style={{ color: "#ff4d4f", marginTop: "10px" }}>
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 生成分享码区域 */}
|
||||||
|
{uploadedFileId && (
|
||||||
|
<div style={{ marginBottom: "40px" }}>
|
||||||
|
<h3>第二步:生成分享码</h3>
|
||||||
|
|
||||||
|
<ShareCodeGenerator
|
||||||
|
fileId={uploadedFileId}
|
||||||
|
onSuccess={handleShareSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
{/* 使用分享码区域 */}
|
||||||
|
<TabPane tab="下载文件" key="download">
|
||||||
|
<div>
|
||||||
|
<h3>使用分享码下载文件</h3>
|
||||||
|
<ShareCodeValidator onValidSuccess={handleValidSuccess} />
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Row, Col, Card, Radio, Button, Space, Typography } from "antd";
|
||||||
|
import { ReloadOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface DeptDistribution {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartControlsProps {
|
||||||
|
chartView: string;
|
||||||
|
setChartView: (view: string) => void;
|
||||||
|
selectedDept: string | null;
|
||||||
|
resetFilter: () => void;
|
||||||
|
deptDistribution: DeptDistribution[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChartControls({
|
||||||
|
chartView,
|
||||||
|
setChartView,
|
||||||
|
selectedDept,
|
||||||
|
resetFilter,
|
||||||
|
deptDistribution,
|
||||||
|
}: ChartControlsProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Row className="mb-4">
|
||||||
|
<Col span={24}>
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<div className="flex justify-between items-center flex-wrap">
|
||||||
|
<Space>
|
||||||
|
<Text strong>图表控制: </Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={chartView}
|
||||||
|
onChange={(e) => setChartView(e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
>
|
||||||
|
<Radio.Button value="all">全部</Radio.Button>
|
||||||
|
<Radio.Button value="pie">饼图</Radio.Button>
|
||||||
|
<Radio.Button value="bar">条形图</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{selectedDept && (
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={resetFilter}>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Text type="secondary">
|
||||||
|
{selectedDept
|
||||||
|
? `已筛选: ${
|
||||||
|
deptDistribution.find((d) => d.id === selectedDept)
|
||||||
|
?.name || ""
|
||||||
|
}`
|
||||||
|
: "点击饼图可筛选部门"}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import React, { lazy, Suspense, useRef, RefObject } from "react";
|
||||||
|
import { Row, Col, Spin, Empty, Button } from "antd";
|
||||||
|
import {
|
||||||
|
BarChartOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import DashboardCard from "../../../components/presentation/dashboard-card";
|
||||||
|
import { message } from "antd";
|
||||||
|
import { EChartsOption } from "echarts-for-react";
|
||||||
|
|
||||||
|
// 懒加载图表组件
|
||||||
|
const ReactECharts = lazy(() => import("echarts-for-react"));
|
||||||
|
|
||||||
|
interface DeptData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DepartmentChartsProps {
|
||||||
|
chartView: string;
|
||||||
|
filteredDeptData: DeptData[];
|
||||||
|
pieOptions: EChartsOption;
|
||||||
|
barOptions: EChartsOption;
|
||||||
|
handleDeptSelection: (params: any) => void;
|
||||||
|
handleExportChart: (chartType: string, chartRef: RefObject<any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DepartmentCharts({
|
||||||
|
chartView,
|
||||||
|
filteredDeptData,
|
||||||
|
pieOptions,
|
||||||
|
barOptions,
|
||||||
|
handleDeptSelection,
|
||||||
|
handleExportChart,
|
||||||
|
}: DepartmentChartsProps): React.ReactElement {
|
||||||
|
const pieChartRef = useRef(null);
|
||||||
|
const barChartRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{/* 条形图 */}
|
||||||
|
{(chartView === "all" || chartView === "bar") && (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={24}
|
||||||
|
md={chartView === "all" ? 12 : 24}
|
||||||
|
lg={chartView === "all" ? 12 : 24}
|
||||||
|
xl={chartView === "all" ? 12 : 24}
|
||||||
|
>
|
||||||
|
<DashboardCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<BarChartOutlined className="mr-2" />
|
||||||
|
<span>部门人员分布 - 条形图</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => handleExportChart("bar", barChartRef)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="min-h-96"
|
||||||
|
contentClassName="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{filteredDeptData.length > 0 ? (
|
||||||
|
<Suspense fallback={<Spin />}>
|
||||||
|
<ReactECharts
|
||||||
|
ref={barChartRef}
|
||||||
|
option={barOptions}
|
||||||
|
style={{ height: "360px", width: "100%" }}
|
||||||
|
notMerge={true}
|
||||||
|
lazyUpdate={true}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无部门数据" className="my-8" />
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 饼图 */}
|
||||||
|
{(chartView === "all" || chartView === "pie") && (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={24}
|
||||||
|
md={chartView === "all" ? 12 : 24}
|
||||||
|
lg={chartView === "all" ? 12 : 24}
|
||||||
|
xl={chartView === "all" ? 12 : 24}
|
||||||
|
>
|
||||||
|
<DashboardCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<PieChartOutlined className="mr-2" />
|
||||||
|
<span>部门人员分布 - 饼图</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => handleExportChart("pie", pieChartRef)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="min-h-96"
|
||||||
|
contentClassName="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{filteredDeptData.length > 0 ? (
|
||||||
|
<Suspense fallback={<Spin />}>
|
||||||
|
<ReactECharts
|
||||||
|
ref={pieChartRef}
|
||||||
|
option={pieOptions}
|
||||||
|
style={{ height: "360px", width: "100%" }}
|
||||||
|
notMerge={true}
|
||||||
|
lazyUpdate={true}
|
||||||
|
onEvents={{
|
||||||
|
click: handleDeptSelection,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无部门数据" className="my-8" />
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Row, Col, Table, Badge, Empty, Tooltip } from "antd";
|
||||||
|
import { TeamOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||||
|
import DashboardCard from "../../../components/presentation/dashboard-card";
|
||||||
|
import { theme } from "antd";
|
||||||
|
|
||||||
|
interface DeptData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DepartmentTableProps {
|
||||||
|
filteredDeptData: DeptData[];
|
||||||
|
staffs: any[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DepartmentTable({
|
||||||
|
filteredDeptData,
|
||||||
|
staffs
|
||||||
|
}: DepartmentTableProps): React.ReactElement {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const deptColumns = [
|
||||||
|
{
|
||||||
|
title: "部门名称",
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "人员数量",
|
||||||
|
dataIndex: "count",
|
||||||
|
key: "count",
|
||||||
|
render: (count: number) => (
|
||||||
|
<Badge
|
||||||
|
count={count}
|
||||||
|
overflowCount={999}
|
||||||
|
style={{ backgroundColor: token.colorPrimary }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "占比",
|
||||||
|
dataIndex: "count",
|
||||||
|
key: "percentage",
|
||||||
|
render: (count: number) => (
|
||||||
|
<span>
|
||||||
|
{staffs && staffs.length > 0
|
||||||
|
? ((count / staffs.length) * 100).toFixed(1) + "%"
|
||||||
|
: "0%"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="mt-4">
|
||||||
|
<Col span={24}>
|
||||||
|
<DashboardCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<TeamOutlined className="mr-2" />
|
||||||
|
<span>部门人员分布详情</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip title="点击饼图可筛选特定部门数据">
|
||||||
|
<InfoCircleOutlined className="text-gray-400" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="min-h-80"
|
||||||
|
>
|
||||||
|
{filteredDeptData.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={filteredDeptData}
|
||||||
|
columns={deptColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showTotal: (total) => `共 ${total} 个部门`,
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无部门数据" className="my-8" />
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Row, Col, Statistic, Tooltip } from "antd";
|
||||||
|
import { UserOutlined, TeamOutlined, IdcardOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||||
|
import DashboardCard from "../../../components/presentation/dashboard-card";
|
||||||
|
import { theme } from "antd";
|
||||||
|
|
||||||
|
interface PositionStats {
|
||||||
|
total: number;
|
||||||
|
distribution: any[];
|
||||||
|
topPosition: {name: string; count: number} | null;
|
||||||
|
vacantPositions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatisticCardsProps {
|
||||||
|
staffs: any[] | undefined;
|
||||||
|
departments: any[] | undefined;
|
||||||
|
positionStats: PositionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatisticCards({
|
||||||
|
staffs,
|
||||||
|
departments,
|
||||||
|
positionStats
|
||||||
|
}: StatisticCardsProps): React.ReactElement {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<DashboardCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>总人员数量</span>
|
||||||
|
<Tooltip title="系统中所有员工的总数">
|
||||||
|
<InfoCircleOutlined className="ml-2 text-gray-400" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="h-32"
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={staffs ? staffs.length : 0}
|
||||||
|
valueStyle={{ color: token.colorPrimary }}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
/>
|
||||||
|
</DashboardCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<DashboardCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>部门总数</span>
|
||||||
|
<Tooltip title="系统中所有部门的数量">
|
||||||
|
<InfoCircleOutlined className="ml-2 text-gray-400" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="h-32"
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={Array.isArray(departments) ? departments.length : 0}
|
||||||
|
valueStyle={{ color: token.colorSuccess }}
|
||||||
|
prefix={<TeamOutlined />}
|
||||||
|
/>
|
||||||
|
</DashboardCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<DashboardCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>岗位分布</span>
|
||||||
|
<Tooltip title="员工岗位类型统计">
|
||||||
|
<InfoCircleOutlined className="ml-2 text-gray-400" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="h-32"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Statistic
|
||||||
|
value={positionStats.total || 0}
|
||||||
|
valueStyle={{ color: "#722ed1" }}
|
||||||
|
prefix={<IdcardOutlined />}
|
||||||
|
suffix={`种岗位`}
|
||||||
|
/>
|
||||||
|
{positionStats.topPosition && (
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
最多人数岗位: {positionStats.topPosition.name} (
|
||||||
|
{positionStats.topPosition.count}人)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { EChartsOption } from "echarts-for-react";
|
||||||
|
|
||||||
|
interface DeptData {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
// 图表配置函数
|
||||||
|
export const getPieChartOptions = (
|
||||||
|
deptData: DeptData[],
|
||||||
|
onEvents?: Record<string, (params: any) => void>
|
||||||
|
): EChartsOption => {
|
||||||
|
const top10Depts = deptData.slice(0, 10);
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
formatter: "{a} <br/>{b}: {c}人 ({d}%)",
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: "vertical",
|
||||||
|
right: 10,
|
||||||
|
top: "center",
|
||||||
|
data: top10Depts.map((dept) => dept.name),
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "部门人数",
|
||||||
|
type: "pie",
|
||||||
|
radius: ["50%", "70%"],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderColor: "#fff",
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: "{b}: {c}人 ({d}%)",
|
||||||
|
position: "outside",
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: "18",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
data: top10Depts.map((dept) => ({
|
||||||
|
value: dept.count,
|
||||||
|
name: dept.name,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBarChartOptions = (deptData: DeptData[]): EChartsOption => {
|
||||||
|
const top10Depts = deptData.slice(0, 10);
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
axisPointer: {
|
||||||
|
type: "shadow",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: "3%",
|
||||||
|
right: "12%",
|
||||||
|
bottom: "3%",
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "value",
|
||||||
|
boundaryGap: [0, 0.01],
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: top10Depts.map((dept) => dept.name),
|
||||||
|
inverse: true,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "人员数量",
|
||||||
|
type: "bar",
|
||||||
|
data: top10Depts.map((dept) => dept.count),
|
||||||
|
itemStyle: {
|
||||||
|
color: function (params) {
|
||||||
|
const colorList = [
|
||||||
|
"#91cc75",
|
||||||
|
"#5470c6",
|
||||||
|
"#ee6666",
|
||||||
|
"#73c0de",
|
||||||
|
"#3ba272",
|
||||||
|
"#fc8452",
|
||||||
|
"#9a60b4",
|
||||||
|
];
|
||||||
|
return colorList[params.dataIndex % colorList.length];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: "right",
|
||||||
|
formatter: "{c}人",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,9 +1,213 @@
|
||||||
import React from "react"
|
import React, { useEffect, useState, useMemo, RefObject } from "react";
|
||||||
|
import { Spin, message } from "antd";
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
|
||||||
export default function Dashboard() {
|
import StatisticCards from "./StatisticCards";
|
||||||
return (
|
import ChartControls from "./ChartControls";
|
||||||
<div >
|
import DepartmentCharts from "./DepartmentCharts";
|
||||||
数据看板(待开发)
|
import DepartmentTable from "./DepartmentTable";
|
||||||
</div>
|
import { getPieChartOptions, getBarChartOptions } from "./char-options";
|
||||||
)
|
interface DeptData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PositionData {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PositionStats {
|
||||||
|
total: number;
|
||||||
|
distribution: PositionData[];
|
||||||
|
topPosition: PositionData | null;
|
||||||
|
vacantPositions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard(): React.ReactElement {
|
||||||
|
// 获取员工数据
|
||||||
|
const { data: staffs, isLoading: staffLoading } = api.staff.findMany.useQuery(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
// 获取部门数据
|
||||||
|
const { data: departments, isLoading: deptLoading } =
|
||||||
|
api.department.findMany.useQuery({});
|
||||||
|
|
||||||
|
// 部门人员分布
|
||||||
|
const [deptDistribution, setDeptDistribution] = useState<DeptData[]>([]);
|
||||||
|
// 选中的部门筛选
|
||||||
|
const [selectedDept, setSelectedDept] = useState<string | null>(null);
|
||||||
|
// 图表视图类型
|
||||||
|
const [chartView, setChartView] = useState<string>("all");
|
||||||
|
// 岗位统计状态
|
||||||
|
const [positionStats, setPositionStats] = useState<PositionStats>({
|
||||||
|
total: 0,
|
||||||
|
distribution: [],
|
||||||
|
topPosition: null,
|
||||||
|
vacantPositions: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理原始数据,提取部门和岗位分布信息
|
||||||
|
useEffect(() => {
|
||||||
|
if (staffs && departments) {
|
||||||
|
// 计算部门分布
|
||||||
|
const deptMap = new Map<string, DeptData>();
|
||||||
|
if (Array.isArray(departments)) {
|
||||||
|
departments.forEach((dept) => {
|
||||||
|
deptMap.set(dept.id, { name: dept.name, count: 0, id: dept.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
staffs.forEach((staff) => {
|
||||||
|
if (staff.deptId && deptMap.has(staff.deptId)) {
|
||||||
|
const deptData = deptMap.get(staff.deptId);
|
||||||
|
if (deptData) {
|
||||||
|
deptData.count += 1;
|
||||||
|
deptMap.set(staff.deptId, deptData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 部门数据排序
|
||||||
|
const deptArray = Array.from(deptMap.values())
|
||||||
|
.filter((dept) => dept.count > 0)
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
setDeptDistribution(deptArray);
|
||||||
|
|
||||||
|
// 岗位分布统计
|
||||||
|
const positionMap = new Map<string, PositionData>();
|
||||||
|
staffs.forEach((staff) => {
|
||||||
|
const position = staff.positionId || "未设置岗位";
|
||||||
|
if (!positionMap.has(position)) {
|
||||||
|
positionMap.set(position, { name: position, count: 0 });
|
||||||
|
}
|
||||||
|
const posData = positionMap.get(position);
|
||||||
|
if (posData) {
|
||||||
|
posData.count += 1;
|
||||||
|
positionMap.set(position, posData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 转换为数组并排序
|
||||||
|
const positionArray = Array.from(positionMap.values()).sort(
|
||||||
|
(a, b) => b.count - a.count
|
||||||
|
);
|
||||||
|
// 找出人数最多的岗位
|
||||||
|
const topPosition = positionArray.length > 0 ? positionArray[0] : null;
|
||||||
|
// 计算空缺岗位数(简化示例,实际可能需要根据业务逻辑调整)
|
||||||
|
const vacantPositions = positionArray.filter((p) => p.count === 0).length;
|
||||||
|
// 更新岗位统计状态
|
||||||
|
setPositionStats({
|
||||||
|
total: positionMap.size,
|
||||||
|
distribution: positionArray,
|
||||||
|
topPosition,
|
||||||
|
vacantPositions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [staffs, departments]);
|
||||||
|
|
||||||
|
// 过滤部门数据
|
||||||
|
const filteredDeptData = useMemo(() => {
|
||||||
|
if (selectedDept) {
|
||||||
|
return deptDistribution.filter((dept) => dept.id === selectedDept);
|
||||||
|
}
|
||||||
|
return deptDistribution;
|
||||||
|
}, [deptDistribution, selectedDept]);
|
||||||
|
|
||||||
|
// 图表配置
|
||||||
|
const pieOptions = useMemo(() => {
|
||||||
|
return getPieChartOptions(filteredDeptData, {});
|
||||||
|
}, [filteredDeptData]);
|
||||||
|
|
||||||
|
const barOptions = useMemo(() => {
|
||||||
|
return getBarChartOptions(filteredDeptData);
|
||||||
|
}, [filteredDeptData]);
|
||||||
|
|
||||||
|
// 处理部门选择
|
||||||
|
const handleDeptSelection = (params: any) => {
|
||||||
|
const selectedDeptName = params.name;
|
||||||
|
const selectedDept = deptDistribution.find(
|
||||||
|
(dept) => dept.name === selectedDeptName
|
||||||
|
);
|
||||||
|
if (selectedDept) {
|
||||||
|
setSelectedDept(selectedDept.id);
|
||||||
|
message.info(`已选择部门: ${selectedDeptName}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出图表为图片
|
||||||
|
const handleExportChart = (chartType: string, chartRef: RefObject<any>) => {
|
||||||
|
if (chartRef.current && chartRef.current.getEchartsInstance) {
|
||||||
|
const chart = chartRef.current.getEchartsInstance();
|
||||||
|
const dataURL = chart.getDataURL({
|
||||||
|
type: "png",
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `部门人员分布-${
|
||||||
|
chartType === "pie" ? "饼图" : "条形图"
|
||||||
|
}.png`;
|
||||||
|
link.href = dataURL;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
message.success(`${chartType === "pie" ? "饼图" : "条形图"}导出成功`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置筛选
|
||||||
|
const resetFilter = () => {
|
||||||
|
setSelectedDept(null);
|
||||||
|
message.success("已重置筛选");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = staffLoading || deptLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">数据看板</h1>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<StatisticCards
|
||||||
|
staffs={staffs}
|
||||||
|
departments={departments}
|
||||||
|
positionStats={positionStats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 图表筛选和控制区 */}
|
||||||
|
<ChartControls
|
||||||
|
chartView={chartView}
|
||||||
|
setChartView={setChartView}
|
||||||
|
selectedDept={selectedDept}
|
||||||
|
resetFilter={resetFilter}
|
||||||
|
deptDistribution={deptDistribution}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 部门分布数据可视化 */}
|
||||||
|
<DepartmentCharts
|
||||||
|
chartView={chartView}
|
||||||
|
filteredDeptData={filteredDeptData}
|
||||||
|
pieOptions={pieOptions}
|
||||||
|
barOptions={barOptions}
|
||||||
|
handleExportChart={handleExportChart}
|
||||||
|
handleDeptSelection={handleDeptSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 详细数据表格 */}
|
||||||
|
<DepartmentTable
|
||||||
|
staffs={staffs}
|
||||||
|
filteredDeptData={filteredDeptData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -1,19 +1,18 @@
|
||||||
import { Layout, Avatar, Dropdown, Menu } from 'antd';
|
import { Layout, Avatar, Dropdown, Menu } from "antd";
|
||||||
import { UserOutlined } from '@ant-design/icons';
|
import { UserOutlined } from "@ant-design/icons";
|
||||||
import { useNavigate, Outlet, useLocation } from 'react-router-dom';
|
import { useNavigate, Outlet, useLocation } from "react-router-dom";
|
||||||
import NavigationMenu from './NavigationMenu';
|
import NavigationMenu from "./NavigationMenu";
|
||||||
import { Header } from 'antd/es/layout/layout';
|
import { Header } from "antd/es/layout/layout";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
export default function MainHeader() {
|
export default function MainHeader() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
{/* 顶部Header */}
|
{/* 顶部Header */}
|
||||||
<Header className="flex justify-end items-center bg-white shadow-sm h-16 px-6">
|
<Header className="flex justify-end items-center bg-white shadow-sm h-16 px-6">
|
||||||
<Avatar className='bg-black'></Avatar>
|
<Avatar className="bg-black"></Avatar>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
{/* 主体布局 */}
|
{/* 主体布局 */}
|
||||||
|
@ -22,7 +21,6 @@ export default function MainHeader() {
|
||||||
<Sider theme="light" width={240} className="shadow-sm">
|
<Sider theme="light" width={240} className="shadow-sm">
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<Content className="overflow-auto p-6 bg-gray-50">
|
<Content className="overflow-auto p-6 bg-gray-50">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
@ -1,130 +1,587 @@
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
/* eslint-disable no-undef */
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||||
|
/* eslint-disable no-unused-expressions */
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { AgGridReact } from "ag-grid-react";
|
||||||
import { api, useStaff } from "@nice/client";
|
import { api, useStaff } from "@nice/client";
|
||||||
import 'ag-grid-community/styles/ag-grid.css';
|
import { Button, CascaderProps, message, Modal, Input, Upload } from "antd";
|
||||||
import 'ag-grid-community/styles/ag-theme-alpine.css';
|
import { areaOptions } from "@web/src/app/main/staffinfo_write/area-options";
|
||||||
import { SetFilterModule } from 'ag-grid-enterprise';
|
import StaffInfoWrite from "@web/src/app/main/staffinfo_write/staffinfo_write.page";
|
||||||
import { Button, message } from 'antd';
|
import { utils, writeFile, read } from "xlsx";
|
||||||
|
import { UploadOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
function getAreaName(codes: string[], level?: number): string {
|
||||||
|
const result: string[] = [];
|
||||||
|
let currentLevel: CascaderProps["options"] = areaOptions;
|
||||||
|
for (const code of codes) {
|
||||||
|
const found = currentLevel?.find((opt) => opt.value === code);
|
||||||
|
if (!found) break;
|
||||||
|
result.push(String(found.label));
|
||||||
|
currentLevel = found.children || [];
|
||||||
|
if (level && result.length >= level) break; // 添加层级控制
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
? result[level - 1] || ""
|
||||||
|
: result.join(" / ") || codes.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加表头提取工具函数
|
||||||
|
function extractHeaders(columns: any[]): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
const extractHeadersRecursive = (cols: any[]) => {
|
||||||
|
cols.forEach((col) => {
|
||||||
|
if ("children" in col && col.children) {
|
||||||
|
extractHeadersRecursive(col.children);
|
||||||
|
} else if (col.headerName) {
|
||||||
|
result.push(col.headerName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
extractHeadersRecursive(columns);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StaffMessage() {
|
export default function StaffMessage() {
|
||||||
const [rowData, setRowData] = useState<any[]>([]);
|
const [rowData, setRowData] = useState<any[]>([]);
|
||||||
const [columnDefs, setColumnDefs] = useState<any[]>([]);
|
const [columnDefs, setColumnDefs] = useState<any[]>([]);
|
||||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||||
const { useCustomFields, softDeleteByIds } = useStaff();
|
const { useCustomFields, softDeleteByIds } = useStaff();
|
||||||
const fields = useCustomFields();
|
const fields = useCustomFields();
|
||||||
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
|
const [currentEditStaff, setCurrentEditStaff] = useState<any>(null);
|
||||||
|
const [gridApi, setGridApi] = useState<any>(null);
|
||||||
|
const [fileNameVisible, setFileNameVisible] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
const [defaultFileName] = useState(
|
||||||
|
`员工数据_${new Date().toISOString().slice(0, 10)}`
|
||||||
|
);
|
||||||
|
const [importVisible, setImportVisible] = useState(false);
|
||||||
|
|
||||||
// 获取数据
|
// 获取数据
|
||||||
const { data: staffData } = api.staff.findMany.useQuery({
|
const { data: staffData } = api.staff.findMany.useQuery({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
fieldValues: {
|
||||||
include: {
|
include: {
|
||||||
fieldValues: {
|
// 添加这两个关联字段
|
||||||
include: {
|
staff: { select: { id: true } }, // 关联员工ID
|
||||||
// 添加这两个关联字段
|
field: { select: { id: true } }, // 关联字段ID
|
||||||
staff: { select: { id: true } }, // 关联员工ID
|
|
||||||
field: { select: { id: true } } // 关联字段ID
|
|
||||||
}
|
|
||||||
},
|
|
||||||
department: true
|
|
||||||
}
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const actionColumns = [{
|
|
||||||
field:"action",
|
|
||||||
width: 50,
|
|
||||||
checkboxSelection: true,
|
|
||||||
headerCheckboxSelection: true,
|
|
||||||
pinned: 'left'
|
|
||||||
}]
|
|
||||||
|
|
||||||
// 新增编辑处理函数
|
|
||||||
const handleEdit = useCallback(async () => {
|
|
||||||
if (selectedRows.length === 0) return;
|
|
||||||
if (selectedRows.length > 1) {
|
|
||||||
message.error('只能选择一个员工进行编辑');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(selectedRows[0]);
|
|
||||||
}, [selectedRows]);
|
|
||||||
|
|
||||||
// 新增删除处理函数
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (selectedRows.length === 0) return;
|
|
||||||
try {
|
|
||||||
await softDeleteByIds.mutateAsync({
|
|
||||||
ids: selectedRows?.map(row => row.id)
|
|
||||||
});
|
|
||||||
message.success('删除成功');
|
|
||||||
} catch (error) {
|
|
||||||
message.error('删除失败');
|
|
||||||
}
|
|
||||||
// 重新获取数据或本地过滤已删除项
|
|
||||||
}, [selectedRows]);
|
|
||||||
|
|
||||||
// 缓存基础列定义
|
|
||||||
const baseColumns = useMemo(() => [
|
|
||||||
{
|
|
||||||
field: 'showname',
|
|
||||||
headerName: '姓名',
|
|
||||||
filter: 'agSetColumnFilter',
|
|
||||||
pinned: 'left'
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
field: 'deptId',
|
department: true,
|
||||||
headerName: '所属部门',
|
},
|
||||||
valueGetter: params => params.data.department?.name,
|
} as any);
|
||||||
filter: 'agSetColumnFilter'
|
// console.log(staffData);
|
||||||
|
const actionColumns = [
|
||||||
|
{
|
||||||
|
field: "action",
|
||||||
|
width: 50,
|
||||||
|
checkboxSelection: true,
|
||||||
|
headerCheckboxSelection: true,
|
||||||
|
pinned: "left",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// 新增编辑处理函数
|
||||||
|
const handleEdit = useCallback(async () => {
|
||||||
|
if (selectedRows.length === 0) return;
|
||||||
|
if (selectedRows.length > 1) {
|
||||||
|
message.error("只能选择一个员工进行编辑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentEditStaff(selectedRows[0]);
|
||||||
|
setIsEditModalVisible(true);
|
||||||
|
}, [selectedRows]);
|
||||||
|
// 处理编辑完成
|
||||||
|
const handleEditComplete = useCallback(() => {
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
setCurrentEditStaff(null);
|
||||||
|
// 刷新表格数据
|
||||||
|
api.staff.findMany.useQuery();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 新增删除处理函数
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (selectedRows.length === 0) return;
|
||||||
|
console.log("待删除的选中行数据:", selectedRows); // 新增调试语句
|
||||||
|
try {
|
||||||
|
await softDeleteByIds.mutateAsync({
|
||||||
|
ids: selectedRows?.map((row) => {
|
||||||
|
console.log("当前行ID:", row.id); // 检查每个ID
|
||||||
|
return row.id;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success("删除成功");
|
||||||
|
} catch (error) {
|
||||||
|
message.error("删除失败");
|
||||||
|
console.error("详细错误信息:", error); // 输出完整错误堆栈
|
||||||
|
}
|
||||||
|
// 重新获取数据或本地过滤已删除项
|
||||||
|
}, [selectedRows]);
|
||||||
|
|
||||||
|
// 缓存基础列定义
|
||||||
|
const baseColumns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
field: "showname",
|
||||||
|
headerName: "姓名",
|
||||||
|
filter: "agSetColumnFilter",
|
||||||
|
pinned: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "deptId",
|
||||||
|
headerName: "所属部门",
|
||||||
|
valueGetter: (params) => params.data.department?.name,
|
||||||
|
filter: "agSetColumnFilter",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 缓存动态列定义
|
||||||
|
const dynamicColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
(fields.data || ([] as any)).map((field) => ({
|
||||||
|
field: field.name,
|
||||||
|
headerName: field.label || field.name,
|
||||||
|
filter: "agSetColumnFilter",
|
||||||
|
cellStyle: { whiteSpace: "pre-line" },
|
||||||
|
autoHeight: true,
|
||||||
|
valueGetter: (params) => {
|
||||||
|
// 获取原始值
|
||||||
|
const rawValue = params.data.fieldValues?.find(
|
||||||
|
(fv: any) => fv.fieldId === field.id
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
// 根据字段类型格式化
|
||||||
|
switch (field.type) {
|
||||||
|
case "cascader":
|
||||||
|
return rawValue ? getAreaName(rawValue.split("/")) : "";
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
// 格式化日期(假设存储的是ISO字符串)
|
||||||
|
return rawValue ? new Date(rawValue).toLocaleDateString() : "";
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
// 换行处理
|
||||||
|
|
||||||
|
return rawValue?.replace(/,/g, "\n");
|
||||||
|
|
||||||
|
default:
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[fields.data]
|
||||||
|
);
|
||||||
|
// 合并列定义
|
||||||
|
useEffect(() => {
|
||||||
|
setColumnDefs([...actionColumns, ...baseColumns, ...dynamicColumns]);
|
||||||
|
}, [baseColumns, dynamicColumns]);
|
||||||
|
|
||||||
|
// 更新行数据
|
||||||
|
useEffect(() => {
|
||||||
|
staffData && setRowData(staffData);
|
||||||
|
}, [staffData]);
|
||||||
|
|
||||||
|
// 修改导出模板处理函数
|
||||||
|
const handleExportTemplate = useCallback(() => {
|
||||||
|
const headerNames = extractHeaders(columnDefs);
|
||||||
|
|
||||||
|
// 创建示例数据行
|
||||||
|
const exampleRow: Record<string, string> = {};
|
||||||
|
// 定义 fieldsList(移到这里)
|
||||||
|
const fieldsList = Array.isArray(fields?.data) ? fields.data : [];
|
||||||
|
|
||||||
|
// 检查是否有选中行
|
||||||
|
if (selectedRows.length > 0) {
|
||||||
|
// 使用第一条选中的记录作为模板数据
|
||||||
|
const templateData = selectedRows[0];
|
||||||
|
|
||||||
|
// 基础字段
|
||||||
|
exampleRow["姓名"] = templateData.showname || "";
|
||||||
|
exampleRow["所属部门"] = templateData.department?.name || "";
|
||||||
|
|
||||||
|
// 处理自定义字段
|
||||||
|
fieldsList.forEach((field: any) => {
|
||||||
|
const fieldValue = templateData.fieldValues?.find(
|
||||||
|
(fv: any) => fv.fieldId === field.id
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
let displayValue = fieldValue;
|
||||||
|
|
||||||
|
// 根据字段类型处理值
|
||||||
|
if (field.type === "cascader" && fieldValue) {
|
||||||
|
displayValue = getAreaName(fieldValue.split("/"));
|
||||||
|
} else if (field.type === "date" && fieldValue) {
|
||||||
|
displayValue = new Date(fieldValue).toLocaleDateString();
|
||||||
|
} else if (field.type === "textarea" && fieldValue) {
|
||||||
|
displayValue = fieldValue.replace(/,/g, "\n");
|
||||||
}
|
}
|
||||||
], []);
|
|
||||||
|
|
||||||
// 缓存动态列定义
|
exampleRow[field.label || field.name] = displayValue || "";
|
||||||
const dynamicColumns = useMemo(() =>
|
});
|
||||||
(fields.data || [] as any).map(field => ({
|
} else {
|
||||||
field: field.name,
|
// 如果没有选中行,使用默认示例数据
|
||||||
headerName: field.label || field.name,
|
exampleRow["姓名"] = "张三";
|
||||||
filter: 'agSetColumnFilter',
|
exampleRow["所属部门"] = "技术部";
|
||||||
valueGetter: params => {
|
|
||||||
// 从 fieldValues 对象中获取值
|
// 添加所有自定义字段的空值
|
||||||
return params.data.fieldValues?.find(
|
fieldsList.forEach((field: any) => {
|
||||||
(fv: any) => fv.fieldId === field.id
|
exampleRow[field.label || field.name] = "";
|
||||||
)?.value;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建空白行供用户填写
|
||||||
|
const emptyRow = headerNames.reduce(
|
||||||
|
(obj, header) => {
|
||||||
|
obj[header] = "";
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建工作簿和工作表
|
||||||
|
const wb = utils.book_new();
|
||||||
|
const ws = utils.json_to_sheet([exampleRow], { header: headerNames });
|
||||||
|
|
||||||
|
// 设置列宽
|
||||||
|
const colWidth = headerNames.map(() => ({ wch: 20 }));
|
||||||
|
ws["!cols"] = colWidth;
|
||||||
|
|
||||||
|
// 在第二行添加提示文字
|
||||||
|
const rowIdx = 2; // 第二行索引
|
||||||
|
const cellRef = utils.encode_cell({ r: rowIdx, c: 0 }); // A3单元格
|
||||||
|
|
||||||
|
const tipText =
|
||||||
|
selectedRows.length > 0
|
||||||
|
? "以上为选中人员数据,请在下方行填写实际数据"
|
||||||
|
: "以上为示例数据,请在下方行填写实际数据";
|
||||||
|
|
||||||
|
ws[cellRef] = { t: "s", v: tipText };
|
||||||
|
|
||||||
|
// 手动添加空白行
|
||||||
|
utils.sheet_add_json(ws, [emptyRow], {
|
||||||
|
skipHeader: true,
|
||||||
|
origin: rowIdx + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 合并提示文字单元格
|
||||||
|
if (!ws["!merges"]) ws["!merges"] = [];
|
||||||
|
ws["!merges"].push({
|
||||||
|
s: { r: rowIdx, c: 0 }, // 起始单元格 A3
|
||||||
|
e: { r: rowIdx, c: Math.min(5, headerNames.length - 1) }, // 结束单元格,跨越多列
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.book_append_sheet(wb, ws, "员工模板");
|
||||||
|
writeFile(wb, `员工数据模板_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
|
}, [columnDefs, selectedRows, fields.data]);
|
||||||
|
|
||||||
|
// 导出数据处理函数
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
setFileNameVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理文件名确认
|
||||||
|
const handleFileNameConfirm = useCallback(() => {
|
||||||
|
setFileNameVisible(false);
|
||||||
|
if (!gridApi) {
|
||||||
|
console.error("Grid API 未正确初始化");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取所有数据
|
||||||
|
const allData = selectedRows.length > 0 ? selectedRows : rowData;
|
||||||
|
|
||||||
|
// 格式化数据
|
||||||
|
const exportData = allData.map((row) => {
|
||||||
|
const formattedRow: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 基础字段
|
||||||
|
formattedRow["姓名"] = row.showname || "";
|
||||||
|
formattedRow["所属部门"] = row.department?.name || "";
|
||||||
|
|
||||||
|
// 动态字段
|
||||||
|
const fieldsList = Array.isArray(fields?.data) ? fields.data : [];
|
||||||
|
fieldsList.forEach((field: any) => {
|
||||||
|
const fieldValue = row.fieldValues?.find(
|
||||||
|
(fv: any) => fv.fieldId === field.id
|
||||||
|
)?.value;
|
||||||
|
let displayValue = fieldValue;
|
||||||
|
|
||||||
|
// 根据字段类型处理值
|
||||||
|
if (field.type === "cascader" && fieldValue) {
|
||||||
|
displayValue = getAreaName(fieldValue.split("/"));
|
||||||
|
} else if (field.type === "date" && fieldValue) {
|
||||||
|
displayValue = new Date(fieldValue).toLocaleDateString();
|
||||||
|
} else if (field.type === "textarea" && fieldValue) {
|
||||||
|
displayValue = fieldValue.replace(/,/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedRow[field.label || field.name] = displayValue || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成工作表
|
||||||
|
const ws = utils.json_to_sheet(exportData);
|
||||||
|
const wb = utils.book_new();
|
||||||
|
utils.book_append_sheet(wb, ws, "员工数据");
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const finalFileName = fileName || defaultFileName;
|
||||||
|
writeFile(wb, `${finalFileName}.xlsx`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("导出失败:", error);
|
||||||
|
message.error("导出失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}, [fileName, defaultFileName, rowData, selectedRows, fields.data, gridApi]);
|
||||||
|
|
||||||
|
// 获取部门数据
|
||||||
|
const { data: departmentsData } = api.department.findMany.useQuery({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理导入数据
|
||||||
|
const createMany = api.staff.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("员工数据导入成功");
|
||||||
|
// 刷新数据
|
||||||
|
api.staff.findMany.useQuery();
|
||||||
|
setImportVisible(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(`导入失败: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理Excel导入数据
|
||||||
|
const handleImportData = useCallback(
|
||||||
|
(excelData: any[]) => {
|
||||||
|
if (excelData.length === 0) {
|
||||||
|
message.warning("没有可导入的数据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保字段数据有效,提取所有有效的字段ID
|
||||||
|
const fieldsList = Array.isArray(fields?.data) ? fields.data : [];
|
||||||
|
console.log(
|
||||||
|
"可用字段列表:",
|
||||||
|
fieldsList.map((f) => ({ id: f.id, name: f.name, label: f.label }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将Excel数据转换为API需要的格式
|
||||||
|
const staffImportData = excelData.map((row, rowIndex) => {
|
||||||
|
console.log(`正在处理第${rowIndex + 1}行数据:`, row);
|
||||||
|
|
||||||
|
const staff: any = {
|
||||||
|
// 设置必要的字段
|
||||||
|
showname: row["姓名"] ? String(row["姓名"]) : "未命名",
|
||||||
|
// 避免使用自动生成的字段
|
||||||
|
fieldValues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理部门关联
|
||||||
|
if (row["所属部门"] && departmentsData) {
|
||||||
|
const deptName = row["所属部门"];
|
||||||
|
const matchedDept = departmentsData.find(
|
||||||
|
(dept) => dept.name === deptName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedDept) {
|
||||||
|
staff.department = {
|
||||||
|
connect: { id: matchedDept.id },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn(`未找到匹配的部门: ${deptName}`);
|
||||||
}
|
}
|
||||||
})),
|
}
|
||||||
[fields.data]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 合并列定义
|
// 我们不在这里处理自定义字段,而是在员工创建后单独处理
|
||||||
useEffect(() => {
|
console.log(`准备创建员工: ${staff.showname}`);
|
||||||
setColumnDefs([...actionColumns, ...baseColumns, ...dynamicColumns]);
|
|
||||||
}, [baseColumns, dynamicColumns]);
|
|
||||||
|
|
||||||
// 更新行数据
|
return staff;
|
||||||
useEffect(() => {
|
});
|
||||||
staffData && setRowData(staffData);
|
|
||||||
}, [staffData]);
|
|
||||||
|
|
||||||
return (
|
// 逐条导入数据
|
||||||
|
if (staffImportData.length > 0) {
|
||||||
|
staffImportData.forEach((staffData, index) => {
|
||||||
|
createMany.mutate(
|
||||||
|
{ data: staffData },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log(`员工创建成功:`, data);
|
||||||
|
message.success(`成功导入第${index + 1}条基础数据`);
|
||||||
|
|
||||||
<>
|
// 员工创建成功后,再单独处理自定义字段值
|
||||||
<Button className='mr-2' danger onClick={handleDelete} style={{ marginBottom: '10px' }}>批量删除</Button>
|
// 由于外键约束问题,我们暂时跳过字段值的创建
|
||||||
<Button type="primary" onClick={handleEdit} style={{ marginBottom: '10px' }}>编辑</Button>
|
// 后续可以添加专门的字段值导入功能
|
||||||
<div className="ag-theme-alpine" style={{ height: 500, width: '100%', padding: '20px' }}>
|
},
|
||||||
<h1 className="text-2xl mb-4">人员总览</h1>
|
onError: (error) => {
|
||||||
<AgGridReact
|
message.error(
|
||||||
modules={[SetFilterModule]}
|
`导入第${index + 1}条数据失败: ${error.message}`
|
||||||
rowData={rowData}
|
);
|
||||||
columnDefs={columnDefs}
|
console.error(`导入失败的详细数据:`, staffData);
|
||||||
pagination={true}
|
},
|
||||||
paginationPageSize={10}
|
}
|
||||||
onSelectionChanged={e => setSelectedRows(e.api.getSelectedRows())}
|
);
|
||||||
rowSelection="multiple"
|
});
|
||||||
/>
|
|
||||||
</div>
|
message.info(`正在导入${staffImportData.length}条员工数据...`);
|
||||||
<Button onClick={() => {
|
}
|
||||||
console.log('字段配置:', fields.data);
|
} catch (error) {
|
||||||
console.log('员工数据:', staffData);
|
console.error("处理导入数据失败:", error);
|
||||||
}}>调试数据</Button>
|
message.error("数据格式错误,导入失败");
|
||||||
</>
|
}
|
||||||
);
|
},
|
||||||
|
[fields.data, createMany, departmentsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
className="bg-red-500 hover:bg-red-600 border-red-500 text-white rounded-md px-4 py-2"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 border-blue-500 text-white rounded-md px-4 py-2"
|
||||||
|
onClick={handleEdit}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setImportVisible(true)}
|
||||||
|
className="bg-orange-500 text-white px-4 py-2 rounded hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
导入Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExportTemplate}
|
||||||
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
导出模板
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
|
||||||
|
>
|
||||||
|
导出所有数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700 rounded-md px-4 py-2"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
调试数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rstaffDataounded-lg shadow-md p-6 mb-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-4">人员总览</h1>
|
||||||
|
|
||||||
|
<div className="ag-theme-alpine w-full min-h-[480px] h-auto overflow-visible">
|
||||||
|
<AgGridReact
|
||||||
|
rowData={rowData}
|
||||||
|
columnDefs={columnDefs}
|
||||||
|
pagination={true}
|
||||||
|
paginationPageSize={10}
|
||||||
|
paginationPageSizeSelector={[10, 20, 50, 100]}
|
||||||
|
onSelectionChanged={(e) => setSelectedRows(e.api.getSelectedRows())}
|
||||||
|
rowSelection="multiple"
|
||||||
|
className="rounded border border-gray-200"
|
||||||
|
headerHeight={40}
|
||||||
|
rowHeight={40}
|
||||||
|
domLayout="autoHeight"
|
||||||
|
onGridReady={(params) => setGridApi(params.api)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑员工信息"
|
||||||
|
open={isEditModalVisible}
|
||||||
|
onCancel={() => setIsEditModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={1000}
|
||||||
|
destroyOnClose={true}
|
||||||
|
>
|
||||||
|
{currentEditStaff && (
|
||||||
|
<StaffInfoWrite
|
||||||
|
staffId={currentEditStaff.id}
|
||||||
|
initialData={currentEditStaff}
|
||||||
|
fieldValues={currentEditStaff.fieldValues}
|
||||||
|
onComplete={handleEditComplete}
|
||||||
|
setIsEditModalVisible={setIsEditModalVisible}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 导出文件名对话框 */}
|
||||||
|
<Modal
|
||||||
|
title="输入文件名"
|
||||||
|
open={fileNameVisible}
|
||||||
|
onOk={handleFileNameConfirm}
|
||||||
|
onCancel={() => setFileNameVisible(false)}
|
||||||
|
okText="导出"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={`默认名称: ${defaultFileName}`}
|
||||||
|
value={fileName}
|
||||||
|
onChange={(e) => setFileName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 导入对话框 */}
|
||||||
|
<Modal
|
||||||
|
title="导入员工数据"
|
||||||
|
open={importVisible}
|
||||||
|
onCancel={() => setImportVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const wb = read(e.target?.result);
|
||||||
|
const data = utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
message.warning("Excel文件中没有数据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.info(`读取到${data.length}条数据,正在处理...`);
|
||||||
|
handleImportData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("解析Excel文件失败:", error);
|
||||||
|
message.error("Excel文件格式错误,请确保使用正确的模板");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>选择Excel文件</Button>
|
||||||
|
</Upload>
|
||||||
|
<div className="mt-4 text-gray-500 text-sm">
|
||||||
|
提示:请使用导出模板功能获取标准模板,按格式填写数据后导入
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -5,11 +5,20 @@ import { areaOptions } from './area-options';
|
||||||
import InfoCard from './infoCard';
|
import InfoCard from './infoCard';
|
||||||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||||
import { baseFields } from "@web/src/app/admin/staffinfo-manage/defaultFields";
|
import { baseFields } from "@web/src/app/admin/staffinfo-manage/defaultFields";
|
||||||
const StaffInfoWrite = () => {
|
import dayjs from 'dayjs'; // 导入 dayjs
|
||||||
|
|
||||||
|
interface StaffInformationProps {
|
||||||
|
staffId?: string; // 可选,编辑模式时提供
|
||||||
|
onComplete?: () => void; // 可选,完成时的回调函数
|
||||||
|
initialData?: any; // 可选,编辑模式时提供的初始数据
|
||||||
|
fieldValues?: any[]; // 可选,编辑模式时提供的字段值数组
|
||||||
|
setIsEditModalVisible?: (visible: boolean) => void; // 可选,编辑模式时提供的回调函数
|
||||||
|
}
|
||||||
|
const StaffInfoWrite = ({ staffId, onComplete, initialData, fieldValues, setIsEditModalVisible }: StaffInformationProps) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
// 修改使用的hook方法
|
// 修改使用的hook方法
|
||||||
const { create, setCustomFieldValue, useCustomFields } = useStaff();
|
const { create, update, setCustomFieldValue, useCustomFields } = useStaff();
|
||||||
const { data: fields, isLoading: fieldsLoading } = useCustomFields();
|
const { data: fields, isLoading: fieldsLoading } = useCustomFields();
|
||||||
const [infoCards, setInfoCards] = useState<any[]>([]);
|
const [infoCards, setInfoCards] = useState<any[]>([]);
|
||||||
|
|
||||||
|
@ -19,7 +28,10 @@ const StaffInfoWrite = () => {
|
||||||
|
|
||||||
// 添加状态来跟踪每个文本区域的高度
|
// 添加状态来跟踪每个文本区域的高度
|
||||||
const [textAreaHeights, setTextAreaHeights] = useState<Record<string, number>>({});
|
const [textAreaHeights, setTextAreaHeights] = useState<Record<string, number>>({});
|
||||||
|
const { data: staffData } = api.staff.findUnique.useQuery(
|
||||||
|
{ where: { id: staffId } },
|
||||||
|
{ enabled: !!staffId } // 只在 staffId 存在时执行查询
|
||||||
|
);
|
||||||
const handleAdd = (content: string[]) => {
|
const handleAdd = (content: string[]) => {
|
||||||
// 将数组内容展开为独立对象
|
// 将数组内容展开为独立对象
|
||||||
const newItems = content.map(text => ({ content: text }));
|
const newItems = content.map(text => ({ content: text }));
|
||||||
|
@ -29,26 +41,51 @@ const StaffInfoWrite = () => {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在组件中添加监听字段变化
|
// 在组件挂载或依赖项变化时填充表单数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 设置默认值
|
if (initialData && fieldValues && fields) {
|
||||||
form.setFieldsValue({
|
// 设置基础字段
|
||||||
hasTrain: false,
|
const formValues: any = {
|
||||||
hasCert: false
|
showname: initialData.showname,
|
||||||
});
|
department: initialData.deptId
|
||||||
|
};
|
||||||
|
|
||||||
// 使用 Form 的 onValuesChange 在外部监听
|
// 设置自定义字段值
|
||||||
const fieldChangeHandler = () => {
|
if (Array.isArray(fieldValues)) {
|
||||||
const values = form.getFieldsValue(['hasTrain', 'hasCert']);
|
fieldValues.forEach(fv => {
|
||||||
setHasTrain(!!values.hasTrain);
|
// 添加类型断言确保 fields 是数组
|
||||||
setHasCert(!!values.hasCert);
|
const field = Array.isArray(fields) ?
|
||||||
};
|
fields.find((f: any) => f.id === fv.fieldId) : undefined;
|
||||||
|
if (field) {
|
||||||
|
let value = fv.value;
|
||||||
|
|
||||||
// 初始化时执行一次
|
// 根据字段类型转换值的格式
|
||||||
fieldChangeHandler();
|
if (field.type === 'cascader' && value) {
|
||||||
|
value = value.split('/');
|
||||||
|
} else if (field.type === 'textarea' && value) {
|
||||||
|
value = value.split(',');
|
||||||
|
} else if (field.type === 'date' && value) {
|
||||||
|
// 使用 dayjs 而不是 new Date 来处理日期
|
||||||
|
value = value ? dayjs(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// 不需要返回取消订阅,因为我们不再使用 subscribe
|
formValues[field.name] = value;
|
||||||
}, [form]);
|
|
||||||
|
// 设置培训和鉴定状态
|
||||||
|
if (field.name === 'hasTrain') {
|
||||||
|
setHasTrain(value === '是');
|
||||||
|
}
|
||||||
|
if (field.name === 'hasCert') {
|
||||||
|
setHasCert(value === '是');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置表单的值
|
||||||
|
form.setFieldsValue(formValues);
|
||||||
|
}
|
||||||
|
}, [initialData, fieldValues, fields, form]);
|
||||||
|
|
||||||
// 按分组组织字段
|
// 按分组组织字段
|
||||||
const fieldGroups = useMemo(() => {
|
const fieldGroups = useMemo(() => {
|
||||||
|
@ -80,8 +117,8 @@ const StaffInfoWrite = () => {
|
||||||
return (
|
return (
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
options={[
|
options={[
|
||||||
{ label: '是', value: true },
|
{ label: '是', value:'是' },
|
||||||
{ label: '否', value: false }
|
{ label: '否', value:'否' }
|
||||||
]}
|
]}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setHasTrain(e.target.value);
|
setHasTrain(e.target.value);
|
||||||
|
@ -96,8 +133,8 @@ const StaffInfoWrite = () => {
|
||||||
return (
|
return (
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
options={[
|
options={[
|
||||||
{ label: '是', value: true },
|
{ label: '是', value: '是' },
|
||||||
{ label: '否', value: false }
|
{ label: '否', value: '否' }
|
||||||
]}
|
]}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setHasCert(e.target.value);
|
setHasCert(e.target.value);
|
||||||
|
@ -169,7 +206,6 @@ const StaffInfoWrite = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onFinish = async (e, values: any) => {
|
const onFinish = async (e, values: any) => {
|
||||||
// values.preventDefault();
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
console.log(values)
|
console.log(values)
|
||||||
try {
|
try {
|
||||||
|
@ -179,151 +215,107 @@ const StaffInfoWrite = () => {
|
||||||
message.error("姓名不能为空");
|
message.error("姓名不能为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 创建基础员工记录
|
|
||||||
console.log('准备创建用户,数据:', { showname: values.showname });
|
let staff;
|
||||||
const staff = await create.mutateAsync({
|
|
||||||
data: {
|
if (staffId) {
|
||||||
showname: values.showname,
|
// 编辑模式 - 更新现有员工
|
||||||
deptId: values.department? values.department : null,
|
console.log('准备更新用户,数据:', { id: staffId, showname: values.showname });
|
||||||
|
try {
|
||||||
|
staff = await update.mutateAsync({
|
||||||
|
where: { id: staffId },
|
||||||
|
data: {
|
||||||
|
showname: values.showname,
|
||||||
|
deptId: values.department || null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('更新员工记录:', staff);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新员工基本信息失败:', error);
|
||||||
|
message.error("更新失败,请重试");
|
||||||
|
setLoading(false);
|
||||||
|
return; // 如果基本信息更新失败,提前返回不继续处理
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
console.log('创建员工记录:', staff);
|
// 创建模式 - 创建新员工
|
||||||
// 创建系统日志记录
|
console.log('准备创建用户,数据:', { showname: values.showname });
|
||||||
await api.systemLog.create.mutateAsync({
|
try {
|
||||||
level: "info",
|
staff = await create.mutateAsync({
|
||||||
module: "人员管理",
|
data: {
|
||||||
action: "创建用户",
|
showname: values.showname,
|
||||||
targetId: staff.id,
|
deptId: values.department ? values.department : null,
|
||||||
targetName: staff.username,
|
}
|
||||||
message: `[${new Date().toLocaleString()}] 用户 ${staff.username} 的人员信息已成功添加`,
|
});
|
||||||
details: {
|
} catch (error) {
|
||||||
fields: validEntries.map(({ field, value }) => ({
|
console.error('创建员工记录失败:', error);
|
||||||
name: field.label,
|
message.error("提交失败,请重试");
|
||||||
value
|
setLoading(false);
|
||||||
}))
|
return; // 如果创建失败,提前返回不继续处理
|
||||||
},
|
|
||||||
status: "success",
|
|
||||||
departmentId: staff.deptId // 用户所属部门
|
|
||||||
});
|
|
||||||
// 过滤有效字段并转换值
|
|
||||||
const validEntries = Object.entries(values)
|
|
||||||
.filter(([key, value]) =>
|
|
||||||
key !== 'showname' && // 新增排除 showname
|
|
||||||
key !== 'username' &&
|
|
||||||
key !== 'department' &&
|
|
||||||
value !== undefined &&
|
|
||||||
value !== null &&
|
|
||||||
value !== ''
|
|
||||||
)
|
|
||||||
.map(([fieldName, value]) => {
|
|
||||||
const field = fields && Array.isArray(fields) ?
|
|
||||||
fields.find((f: any) => f.name === fieldName) : undefined;
|
|
||||||
|
|
||||||
// 处理特殊字段类型
|
|
||||||
let processedValue = value;
|
|
||||||
if (field?.type === 'date') { //日期类型
|
|
||||||
processedValue = (value as Date)?.toISOString();
|
|
||||||
} else if (field?.type === 'cascader' && Array.isArray(value)) { //级联选择器
|
|
||||||
processedValue = value?.join('/');
|
|
||||||
}else if(field?.type === 'textarea'){ //多行文本
|
|
||||||
processedValue = (value as string[])?.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { field, value: processedValue };
|
|
||||||
})
|
|
||||||
.filter(item => item.field?.id);
|
|
||||||
|
|
||||||
// 批量提交自定义字段
|
|
||||||
await Promise.all(
|
|
||||||
validEntries.map(({ field, value }) =>
|
|
||||||
setCustomFieldValue.mutateAsync({
|
|
||||||
staffId: staff.id,
|
|
||||||
fieldId: field.id,
|
|
||||||
value: String(value)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
console.log('自定义字段提交成功', staff.username);
|
|
||||||
// 记录系统日志 - 用户创建成功
|
|
||||||
const timestamp = new Date().toLocaleString();
|
|
||||||
const logs = [];
|
|
||||||
// 记录用户创建
|
|
||||||
logs.push(`${timestamp} - 用户创建成功:${staff.username}`);
|
|
||||||
// 记录人员信息添加
|
|
||||||
logs.push(`[${timestamp}] 用户 ${staff.username} 的人员信息已成功添加`);
|
|
||||||
// 记录每个字段的详细信息
|
|
||||||
validEntries.forEach(({ field, value }) => {
|
|
||||||
if (field && field.label && value) {
|
|
||||||
logs.push(`[${timestamp}] 提交的数据: ${field.label}=${value}`);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
// 根据字段分组记录
|
|
||||||
const fieldsByGroup = validEntries.reduce((groups, { field, value }) => {
|
|
||||||
if (field && field.group && value) {
|
|
||||||
if (!groups[field.group]) {
|
|
||||||
groups[field.group] = [];
|
|
||||||
}
|
|
||||||
groups[field.group].push({ field, value });
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// 为每个分组记录信息
|
// 只有在上面的操作成功后才继续处理自定义字段
|
||||||
Object.entries(fieldsByGroup).forEach(([groupName, fields]) => {
|
|
||||||
const groupValues = (fields as any[]).map(f => `${f.field.label}=${f.value}`).join(', ');
|
|
||||||
logs.push(`[${timestamp}] ${staff.username} 的${groupName}:${groupValues || '无'}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取现有日志
|
|
||||||
let currentLogs = [];
|
|
||||||
try {
|
try {
|
||||||
const storedLogs = localStorage.getItem('systemLogs');
|
// 过滤有效字段并转换值
|
||||||
currentLogs = storedLogs ? JSON.parse(storedLogs) : [];
|
const validEntries = Object.entries(values)
|
||||||
|
.filter(([key, value]) =>
|
||||||
|
key !== 'showname' &&
|
||||||
|
key !== 'username' &&
|
||||||
|
key !== 'department' &&
|
||||||
|
value !== undefined &&
|
||||||
|
value !== null &&
|
||||||
|
value !== ''
|
||||||
|
)
|
||||||
|
.map(([fieldName, value]) => {
|
||||||
|
const field = fields && Array.isArray(fields) ?
|
||||||
|
fields.find((f: any) => f.name === fieldName) : undefined;
|
||||||
|
|
||||||
|
// 处理特殊字段类型
|
||||||
|
let processedValue = value;
|
||||||
|
if (field?.type === 'date') {
|
||||||
|
processedValue = (value as any)?.format?.('YYYY-MM-DD') || value;
|
||||||
|
} else if (field?.type === 'cascader' && Array.isArray(value)) {
|
||||||
|
processedValue = value?.join('/');
|
||||||
|
} else if(field?.type === 'textarea'){
|
||||||
|
processedValue = Array.isArray(value) ? value.join(',') : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { field, value: processedValue };
|
||||||
|
})
|
||||||
|
.filter(item => item.field?.id);
|
||||||
|
|
||||||
|
// 批量提交自定义字段
|
||||||
|
await Promise.all(
|
||||||
|
validEntries.map(({ field, value }) =>
|
||||||
|
setCustomFieldValue.mutateAsync({
|
||||||
|
staffId: staff.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: String(value)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只有当所有操作都成功时才显示成功消息
|
||||||
|
message.success(staffId ? "信息更新成功" : "信息提交成功");
|
||||||
|
|
||||||
|
// 关闭编辑模态窗口(如果在编辑模式)
|
||||||
|
if (staffId && setIsEditModalVisible) {
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有回调函数,调用它
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
} else if (!staffId) {
|
||||||
|
// 如果是新建模式,重置表单
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('读取系统日志失败', error);
|
console.error('设置自定义字段值失败:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加新日志(倒序添加,最新的在最前面)
|
|
||||||
const updatedLogs = [...logs.reverse(), ...currentLogs];
|
|
||||||
|
|
||||||
// 保存到 localStorage
|
|
||||||
localStorage.setItem('systemLogs', JSON.stringify(updatedLogs));
|
|
||||||
|
|
||||||
// 如果有全局变量,也更新它
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
(window as any).globalLogs = updatedLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success("信息提交成功");
|
|
||||||
form.resetFields();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('提交出错:', error);
|
console.error('提交出错:', error);
|
||||||
|
message.error(staffId ? "更新失败,请重试" : "提交失败,请重试");
|
||||||
// 记录错误日志
|
|
||||||
const timestamp = new Date().toLocaleString();
|
|
||||||
const logMessage = `${timestamp} - 创建用户失败:${values.username || '未知用户'}, 错误: ${error.message || '未知错误'}`;
|
|
||||||
|
|
||||||
// 获取现有日志
|
|
||||||
let currentLogs = [];
|
|
||||||
try {
|
|
||||||
const storedLogs = localStorage.getItem('systemLogs');
|
|
||||||
currentLogs = storedLogs ? JSON.parse(storedLogs) : [];
|
|
||||||
} catch (err) {
|
|
||||||
console.error('读取系统日志失败', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加新日志
|
|
||||||
const updatedLogs = [logMessage, ...currentLogs];
|
|
||||||
|
|
||||||
// 保存到 localStorage
|
|
||||||
localStorage.setItem('systemLogs', JSON.stringify(updatedLogs));
|
|
||||||
|
|
||||||
// 如果有全局变量,也更新它
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
(window as any).globalLogs = updatedLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
message.error("提交失败,请重试");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
@ -335,7 +327,7 @@ const StaffInfoWrite = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
<h1 className="text-2xl font-bold mb-6">人员信息管理</h1>
|
<h1 className="text-2xl font-bold mb-6">人员信息填报</h1>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|
|
@ -4,7 +4,10 @@ import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
// 创建一个全局变量来存储日志
|
// 创建一个全局变量来存储日志
|
||||||
let globalLogs: string[] = [];
|
let globalLogs: string[] = [];
|
||||||
|
const clearLogs = () => {
|
||||||
|
localStorage.removeItem('systemLogs');
|
||||||
|
setLogs([]);
|
||||||
|
};
|
||||||
// 添加日志的函数
|
// 添加日志的函数
|
||||||
export const addLog = (log: string) => {
|
export const addLog = (log: string) => {
|
||||||
const timestamp = new Date().toLocaleString();
|
const timestamp = new Date().toLocaleString();
|
||||||
|
@ -29,6 +32,9 @@ const SystemLogPage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
<h1 className="text-2xl font-bold mb-6">系统日志</h1>
|
<h1 className="text-2xl font-bold mb-6">系统日志</h1>
|
||||||
|
<button onClick={clearLogs} className="mb-4 bg-red-500 text-white p-2 rounded">
|
||||||
|
清除日志
|
||||||
|
</button>
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p className="text-gray-500">暂无系统日志</p>
|
<p className="text-gray-500">暂无系统日志</p>
|
||||||
|
@ -47,3 +53,7 @@ const SystemLogPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemLogPage;
|
export default SystemLogPage;
|
||||||
|
function setLogs(arg0: undefined[]) {
|
||||||
|
throw new Error('Function not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Layout } from "antd";
|
import { Layout } from "antd";
|
||||||
|
|
||||||
import { adminRoute } from "@web/src/routes/admin-route";
|
|
||||||
import AdminSidebar from "./AdminSidebar";
|
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
|
|
0
apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx
Normal file → Executable file
0
apps/web/src/components/models/department/department-children-select.tsx
Normal file → Executable file
0
apps/web/src/components/models/department/department-children-select.tsx
Normal file → Executable file
0
apps/web/src/components/models/trainContent/train-content-tree-select.tsx
Normal file → Executable file
0
apps/web/src/components/models/trainContent/train-content-tree-select.tsx
Normal file → Executable file
|
@ -1,9 +1,9 @@
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
IndexRouteObject,
|
IndexRouteObject,
|
||||||
Link,
|
Link,
|
||||||
NonIndexRouteObject,
|
NonIndexRouteObject,
|
||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import ErrorPage from "../app/error";
|
import ErrorPage from "../app/error";
|
||||||
import LoginPage from "../app/login";
|
import LoginPage from "../app/login";
|
||||||
|
@ -19,70 +19,73 @@ import { adminRoute } from "./admin-route";
|
||||||
import AdminLayout from "../components/layout/admin/AdminLayout";
|
import AdminLayout from "../components/layout/admin/AdminLayout";
|
||||||
import SystemLogPage from "../app/main/systemlog/SystemLogPage";
|
import SystemLogPage from "../app/main/systemlog/SystemLogPage";
|
||||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
breadcrumb?: string;
|
breadcrumb?: string;
|
||||||
}
|
}
|
||||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
breadcrumb?: string;
|
breadcrumb?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomNonIndexRouteObject extends NonIndexRouteObject {
|
export interface CustomNonIndexRouteObject extends NonIndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
children?: CustomRouteObject[];
|
children?: CustomRouteObject[];
|
||||||
breadcrumb?: string;
|
breadcrumb?: string;
|
||||||
handle?: {
|
handle?: {
|
||||||
crumb: (data?: any) => void;
|
crumb: (data?: any) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type CustomRouteObject =
|
export type CustomRouteObject =
|
||||||
| CustomIndexRouteObject
|
| CustomIndexRouteObject
|
||||||
| CustomNonIndexRouteObject;
|
| CustomNonIndexRouteObject;
|
||||||
export const routes: CustomRouteObject[] = [
|
export const routes: CustomRouteObject[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
handle: {
|
handle: {
|
||||||
crumb() {
|
crumb() {
|
||||||
return <Link to={"/"}>主页</Link>;
|
return <Link to={"/"}>主页</Link>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
element: <MainLayout></MainLayout>,
|
element: <MainLayout></MainLayout>,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
element:<Dashboard></Dashboard>,
|
element: <Dashboard></Dashboard>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/staffinformation",
|
path: "/staffinformation",
|
||||||
element: <StaffInformation></StaffInformation>,
|
element: <StaffInformation></StaffInformation>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/staff",
|
path: "/staff",
|
||||||
element: <StaffMessage></StaffMessage>,
|
element: <StaffMessage></StaffMessage>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/systemlog",
|
path: "/systemlog",
|
||||||
element: <SystemLogPage></SystemLogPage>,
|
element: <SystemLogPage></SystemLogPage>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
element: <AdminLayout></AdminLayout>,
|
element: <AdminLayout></AdminLayout>,
|
||||||
children:adminRoute.children
|
children: adminRoute.children,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
|
},
|
||||||
],
|
{
|
||||||
},
|
path: "/login",
|
||||||
{
|
breadcrumb: "登录",
|
||||||
path: "/login",
|
element: <LoginPage></LoginPage>,
|
||||||
breadcrumb: "登录",
|
},
|
||||||
element: <LoginPage></LoginPage>,
|
{
|
||||||
},
|
index: true,
|
||||||
|
path: "/",
|
||||||
|
element: <DeptSettingPage></DeptSettingPage>,
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const router = createBrowserRouter(routes);
|
export const router = createBrowserRouter(routes);
|
||||||
|
|
|
@ -100,7 +100,7 @@ server {
|
||||||
# 仅供内部使用
|
# 仅供内部使用
|
||||||
internal;
|
internal;
|
||||||
# 代理到认证服务
|
# 代理到认证服务
|
||||||
proxy_pass http://192.168.252.77:3000/auth/file;
|
proxy_pass http://192.168.252.77:3001/auth/file;
|
||||||
|
|
||||||
# 请求优化:不传递请求体
|
# 请求优化:不传递请求体
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
{
|
||||||
|
"name": "nice-stack",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "nice-stack",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"echarts-for-react": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "5.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts-for-react": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"size-sensor": "^1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0",
|
||||||
|
"react": "^15.0.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "18.2.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/size-sensor": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "5.6.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"echarts-for-react": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
|
||||||
|
"requires": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"size-sensor": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fast-deep-equal": {
|
||||||
|
"version": "3.1.3"
|
||||||
|
},
|
||||||
|
"js-tokens": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"version": "18.2.0",
|
||||||
|
"peer": true,
|
||||||
|
"requires": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size-sensor": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="
|
||||||
|
},
|
||||||
|
"zrender": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,5 +11,9 @@
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "insiinc",
|
"author": "insiinc",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"echarts-for-react": "^3.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -466,7 +466,8 @@ model Staff {
|
||||||
enabled Boolean? @default(true)
|
enabled Boolean? @default(true)
|
||||||
officerId String? @map("officer_id")
|
officerId String? @map("officer_id")
|
||||||
phoneNumber String? @map("phone_number")
|
phoneNumber String? @map("phone_number")
|
||||||
|
age Int?@map("age")
|
||||||
|
sex String?@map("sex")
|
||||||
// 部门关系
|
// 部门关系
|
||||||
domainId String? @map("domain_id")
|
domainId String? @map("domain_id")
|
||||||
deptId String? @map("dept_id")
|
deptId String? @map("dept_id")
|
||||||
|
@ -567,7 +568,7 @@ model SystemLog {
|
||||||
// 关联部门
|
// 关联部门
|
||||||
departmentId String? @map("department_id")
|
departmentId String? @map("department_id")
|
||||||
department Department? @relation(fields: [departmentId], references: [id])
|
department Department? @relation(fields: [departmentId], references: [id])
|
||||||
message String @map("message") // 完整的日志文本内容
|
message String? @map("message") // 完整的日志文本内容
|
||||||
// 优化索引
|
// 优化索引
|
||||||
@@index([timestamp])
|
@@index([timestamp])
|
||||||
@@index([level])
|
@@index([level])
|
||||||
|
|
|
@ -154,8 +154,6 @@ export const trainSituationDetailSelect: Prisma.TrainSituationSelect = {
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
age: true,
|
|
||||||
sex: true,
|
|
||||||
absent: true,
|
absent: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -173,8 +171,6 @@ export const staffDetailSelect: Prisma.StaffSelect = {
|
||||||
username: true,
|
username: true,
|
||||||
deptId: true,
|
deptId: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
age: true,
|
|
||||||
sex: true,
|
|
||||||
absent: true,
|
absent: true,
|
||||||
trainSituations:{
|
trainSituations:{
|
||||||
select:{
|
select:{
|
||||||
|
|
17027
pnpm-lock.yaml
17027
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue