This commit is contained in:
Li1304553726 2025-03-25 23:09:48 +08:00
commit dd9e3fb593
20 changed files with 1174 additions and 239 deletions

View File

@ -1,15 +1,16 @@
import { Module } from '@nestjs/common';
import { StaffService } from './staff.service';
import { StaffRouter } from './staff.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentModule } from '../department/department.module';
import { StaffController } from './staff.controller';
import { StaffRowService } from './staff.row.service';
import { DepartmentModule } from '../department/department.module';
import { TrpcModule } from '../../trpc/trpc.module';
import { StaffRouter } from './staff.router';
import { StaffRowService } from './staff.row.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
imports: [DepartmentModule],
providers: [StaffService, StaffRouter, TrpcService, StaffRowService],
exports: [StaffService, StaffRouter, StaffRowService],
controllers: [StaffController],
providers: [StaffService, StaffRouter, StaffRowService, TrpcService],
exports: [StaffService, StaffRouter],
controllers: [StaffController, ],
})
export class StaffModule { }
export class StaffModule {}

View File

@ -95,5 +95,50 @@ export class StaffRouter {
.query(async ({ input }) => {
return await this.staffService.findUnique(input);
}),
addCustomField: this.trpc.procedure
.input(z.object({
name: z.string(),
label: z.string().optional(),
type: z.string(), // text, number, date, select 等
required: z.boolean().optional(),
order: z.number().optional(),
options: z.any().optional(), // 对于选择类型字段的可选值
group: z.string().optional(), // 字段分组
}))
.mutation(({ input }) => {
return this.staffService.addCustomField(input as any);
}),
updateCustomField: this.trpc.procedure
.input(z.object({
id: z.string(),
name: z.string().optional(),
label: z.string().optional(),
type: z.string().optional(),
required: z.boolean().optional(),
order: z.number().optional(),
options: z.any().optional(),
group: z.string().optional(),
}))
.mutation(({ input }) => {
return this.staffService.updateCustomField(input as any);
}),
deleteCustomField: this.trpc.procedure
.input(z.string())
.mutation(({ input }) => {
return this.staffService.deleteCustomField(input as any);
}),
getCustomFields: this.trpc.procedure
.query(() => {
return this.staffService.getCustomFields();
}),
setCustomFieldValue: this.trpc.procedure
.input(z.object({
staffId: z.string(),
fieldId: z.string(),
value: z.string().optional(),
}))
.mutation(({ input }) => {
return this.staffService.setCustomFieldValue(input as any);
}),
});
}

View File

@ -17,6 +17,7 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
constructor(private readonly departmentService: DepartmentService) {
super(db, ObjectType.STAFF, true);
}
/**
* staff的记录
* @param deptId id
@ -36,30 +37,78 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
});
return result;
}
async create(args: Prisma.StaffCreateArgs) {
const { data } = args;
await this.validateUniqueFields(data);
const createData = {
...data,
password: await argon2.hash((data.password || '123456') as string),
};
const result = await super.create({ ...args, data: createData });
this.emitDataChangedEvent(result, CrudOperation.CREATED);
return result;
const { data, select } = args;
const { fieldValues, ...staffData } = data as any;
// 创建员工基本信息
const staff = await super.create({
...args,
data: {
...staffData,
password: await argon2.hash((staffData.password || '123456') as string),
},
});
// 如果有自定义字段值,创建它们
if (fieldValues) {
await db.staffFieldValue.createMany({
data: Object.entries(fieldValues).map(([fieldId, value]) => ({
staffId: staff.id,
fieldId,
value: String(value),
})),
});
}
this.emitDataChangedEvent(staff, CrudOperation.CREATED);
return staff;
}
async update(args: Prisma.StaffUpdateArgs) {
const { data, where } = args;
await this.validateUniqueFields(data, where.id);
const updateData = {
...data,
...(data.password && {
password: await argon2.hash(data.password as string),
const { fieldValues, ...staffData } = data as any;
// 更新员工基本信息
const staff = await super.update({
...args,
data: {
...staffData,
...(staffData.password && {
password: await argon2.hash(staffData.password as string),
}),
};
const result = await super.update({ ...args, data: updateData });
this.emitDataChangedEvent(result, CrudOperation.UPDATED);
return result;
},
});
// 如果有自定义字段值,更新它们
if (fieldValues) {
await Promise.all(
Object.entries(fieldValues).map(([fieldId, value]) =>
db.staffFieldValue.upsert({
where: {
staffId_fieldId: {
staffId: staff.id,
fieldId,
},
},
create: {
staffId: staff.id,
fieldId,
value: String(value),
},
update: {
value: String(value),
},
}),
),
);
}
this.emitDataChangedEvent(staff, CrudOperation.UPDATED);
return staff;
}
private async validateUniqueFields(data: any, excludeId?: string) {
const uniqueFields = [
{
@ -119,72 +168,91 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
}
}
// /**
// * 根据关键词或ID集合查找员工
// * @param data 包含关键词、域ID和ID集合的对象
// * @returns 匹配的员工记录列表
// */
// async findMany(data: z.infer<typeof StaffMethodSchema.findMany>) {
// const { keyword, domainId, ids, deptId, limit = 30 } = data;
// const idResults = ids
// ? await db.staff.findMany({
// where: {
// id: { in: ids },
// deletedAt: null,
// domainId,
// deptId,
// },
// select: {
// id: true,
// showname: true,
// username: true,
// deptId: true,
// domainId: true,
// department: true,
// domain: true,
// },
// })
// : [];
async findUnique(args: Prisma.StaffFindUniqueArgs) {
const staff = await super.findUnique(args);
if (!staff) return null;
// const mainResults = await db.staff.findMany({
// where: {
// deletedAt: null,
// domainId,
// deptId,
// OR: (keyword || ids) && [
// { showname: { contains: keyword } },
// {
// username: {
// contains: keyword,
// },
// },
// { phoneNumber: { contains: keyword } },
// // {
// // id: { in: ids },
// // },
// ],
// },
// select: {
// id: true,
// showname: true,
// username: true,
// deptId: true,
// domainId: true,
// department: true,
// domain: true,
// },
// orderBy: { order: 'asc' },
// take: limit !== -1 ? limit : undefined,
// });
// // Combine results, ensuring no duplicates
// const combinedResults = [
// ...mainResults,
// ...idResults.filter(
// (idResult) =>
// !mainResults.some((mainResult) => mainResult.id === idResult.id),
// ),
// ];
// 获取自定义字段值
const fieldValues = await db.staffFieldValue.findMany({
where: { staffId: staff.id },
include: { field: true },
});
// return combinedResults;
// }
return {
...staff,
fieldValues: fieldValues.reduce((acc, { field, value }) => ({
...acc,
[field.name]: value,
}), {}),
};
}
async addCustomField(data: {
name: string;
label?: string;
type: string;
required?: boolean;
order?: number;
options?: any;
group?: string;
}) {
return this.prisma.staffField.create({
data: {
...data,
},
});
}
async updateCustomField(data: {
id: string;
name?: string;
label?: string;
type?: string;
required?: boolean;
order?: number;
options?: any;
group?: string;
}) {
const { id, ...updateData } = data;
return this.prisma.staffField.update({
where: { id },
data: updateData,
});
}
async deleteCustomField(id: string) {
return this.prisma.staffField.delete({
where: { id },
});
}
async getCustomFields() {
return this.prisma.staffField.findMany({
orderBy: { order: 'asc' },
});
}
async setCustomFieldValue(data: {
staffId: string;
fieldId: string;
value?: string;
}) {
const { staffId, fieldId, value } = data;
return this.prisma.staffFieldValue.upsert({
where: {
staffId_fieldId: {
staffId,
fieldId,
}
},
create: {
staffId,
fieldId,
value,
},
update: {
value,
},
});
}
}

View File

@ -37,7 +37,7 @@ export class TrpcRouter {
private readonly resource: ResourceRouter,
private readonly trainContent: TrainContentRouter,
private readonly trainSituation:TrainSituationRouter,
private readonly dailyTrain:DailyTrainRouter
private readonly dailyTrain:DailyTrainRouter,
) {}
getRouter() {
return;

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { message, Modal, Button } from 'antd';
import { useStaff } from '@nice/client';
import { defaultFields } from './defaultFields';
type DefaultFieldInitializerProps = {
fields: any[] | undefined;
isProcessing: boolean;
setIsProcessing: (isProcessing: boolean) => void;
isInitConfirmVisible: boolean;
setIsInitConfirmVisible: (isVisible: boolean) => void;
};
const checkFieldExists = (fields: any[] | undefined, name: string) => {
return fields?.some(field => field.name === name);
};
const DefaultFieldInitializer: React.FC<DefaultFieldInitializerProps> = ({
fields,
isProcessing,
setIsProcessing,
isInitConfirmVisible,
setIsInitConfirmVisible
}) => {
const { addCustomField } = useStaff();
const initializeDefaultFields = async () => {
try {
setIsProcessing(true);
// 检查是否已有数据
if (fields && fields.length > 0) {
message.warning('数据库中已存在字段数据,请先清空数据后再初始化');
return;
}
// 使用 Promise.all 批量处理
await Promise.all(
defaultFields.map(async (field) => {
try {
if (!checkFieldExists(fields, field.name)) {
await addCustomField.mutateAsync(field);
}
} catch (error) {
console.error(`添加字段 ${field.name} 失败:`, error);
}
})
);
message.success('默认字段初始化成功');
setIsInitConfirmVisible(false);
} catch (error) {
message.error('初始化失败:' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsProcessing(false);
}
};
return (
<>
<Button
onClick={() => setIsInitConfirmVisible(true)}
type="default"
loading={isProcessing}
>
</Button>
<Modal
title="确认初始化"
open={isInitConfirmVisible}
onOk={initializeDefaultFields}
onCancel={() => setIsInitConfirmVisible(false)}
confirmLoading={isProcessing}
>
<p></p>
</Modal>
</>
);
};
export default DefaultFieldInitializer;

View File

@ -0,0 +1,174 @@
export const defaultFields = [
// 基本信息组
{ name: 'username', label: '用户名', type: 'text',required: true, group: '基本信息', order: 1 },
{ name: 'showname', label: '显示名称', type: 'text', group: '基本信息', order: 2 },
{ name: 'idNumber', label: '身份证号', type: 'text', group: '基本信息', order: 3 },
{ name: 'officerId', label: '警号', type: 'text', group: '基本信息', order: 4 },
{ name: 'phoneNumber', label: '手机号', type: 'text', group: '基本信息', order: 5 },
{ name: 'age', label: '年龄', type: 'number', group: '基本信息', order: 6 },
{ name: 'sex',
label: '性别',
type: 'radio',
options: [
{ label: '男', value: 'male' },
{ label: '女', value: 'female' },
],
group: '基本信息',
order: 7 },
{ name: 'bloodType',
label: '血型',
type: 'select',
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'AB', value: 'AB' },
{ label: 'O', value: 'O' },
],
group: '基本信息',
order: 8 },
{ name: 'birthplace', label: '籍贯', type: 'text', group: '基本信息', order: 9 },
{ name: 'source', label: '来源', type: 'text', group: '基本信息', order: 10 },
// 政治信息组
{ name: 'politicalStatus',
label: '政治面貌',
type: 'select',
options: [
{ label: '中共党员', value: '中共党员' },
{ label: '中共预备党员', value: '中共预备党员' },
{ label: '共青团员', value: '共青团员' },
{ label: '群众', value: '群众' },
],
group: '政治信息',
order: 11 },
{ name: 'partyPosition', label: '党内职务', type: 'text', group: '政治信息', order: 12 },
// 职务信息组
{ name: 'rank',
label: '衔职级别',
type: 'select',
options: [
{ label: '', value: '' },
{ label: '', value: '' },
],
group: '职务信息',
order: 13 },
{ name: 'rankDate', label: '衔职时间', type: 'date', group: '职务信息', order: 14 },
{ name: 'proxyPosition', label: '代理职务', type: 'text', group: '职务信息', order: 15 },
{ name: 'post', label: '岗位', type: 'text', group: '职务信息', order: 16 },
// 入职信息组
{ name: 'hireDate', label: '入职时间', type: 'date', group: '入职信息', order: 17 },
{ name: 'seniority', label: '工龄认定时间', type: 'date', group: '入职信息', order: 18 },
{ name: 'sourceType', label: '来源类型', type: 'text', group: '入职信息', order: 19 },
{ name: 'isReentry',
label: '是否二次入职',
type: 'radio',
options: [
{ label: '是', value: '是' },
{ label: '否', value: '否' },
],
group: '入职信息',
order: 20 },
{ name: 'isExtended',
label: '是否延期服役',
type: 'radio',
options: [
{ label: '是', value: '是' },
{ label: '否', value: '否' },
],
group: '入职信息',
order: 21 },
{ name: 'currentPositionDate', label: '现岗位开始时间', type: 'date', group: '入职信息', order: 22 },
// 教育信息组
{ name: 'education',
label: '学历',
type: 'select',
options: [
{ label: '博士', value: '博士' },
{ label: '硕士', value: '硕士' },
{ label: '本科', value: '本科' },
{ label: '专科', value: '专科' },
{ label: '中专', value: '中专' },
{ label: '高中', value: '高中' },
{ label: '初中', value: '初中' },
{ label: '小学', value: '小学' },
],
group: '教育信息',
order: 23 },
{ name: 'educationType',
label: '学历形式',
type: 'select',
options: [
{ label: '全日制', value: '全日制' },
{ label: '非全日制', value: '非全日制' },
],
group: '教育信息',
order: 24 },
{ name: 'isGraduated',
label: '是否毕业',
type: 'radio',
options: [
{ label: '是', value: '是' },
{ label: '否', value: '否' },
],
group: '教育信息',
order: 25 },
{ name: 'major', label: '专业', type: 'text', group: '教育信息', order: 26 },
{ name: 'foreignLang', label: '外语能力', type: 'text', group: '教育信息', order: 27 },
// 培训信息组
{ name: 'trainType', label: '培训类型', type: 'text', group: '培训信息', order: 28 },
{ name: 'trainInstitute', label: '培训机构', type: 'text', group: '培训信息', order: 29 },
{ name: 'trainMajor', label: '培训专业', type: 'text', group: '培训信息', order: 30 },
{ name: 'hasTrain',
label: '是否参加培训',
type: 'radio',
options: [
{ label: '是', value: '是' },
{ label: '否', value: '否' },
],
group: '培训信息',
order: 31 },
// 鉴定信息组
{ name: 'certRank', label: '鉴定等级', type: 'text', group: '鉴定信息', order: 32 },
{ name: 'certWork', label: '鉴定工种', type: 'text', group: '鉴定信息', order: 33 },
{ name: 'hasCert',
label: '是否参加鉴定',
type: 'radio',
options: [
{ label: '是', value: '是' },
{ label: '否', value: '否' },
],
group: '鉴定信息',
order: 34 },
// 工作信息组
{ name: 'equipment', label: '操作维护装备', type: 'textarea', group: '工作信息', order: 35 },
{ name: 'projects', label: '演训任务经历', type: 'textarea', group: '工作信息', order: 36 },
{ name: 'awards', label: '奖励信息', type: 'textarea', group: '工作信息', order: 37 },
{ name: 'punishments', label: '处分信息', type: 'textarea', group: '工作信息', order: 38 },
];
export const FieldTypeOptions = [
{ label: '文本', value: 'text' },
{ label: '数字', value: 'number' },
{ label: '日期', value: 'date' },
{ label: '选择', value: 'select' },
{ label: '单选', value: 'radio' },
{ label: '多行文本', value: 'textarea' },
];
export const GroupOptions = [
{ label: '个人基本信息', value: '个人基本信息' },
{ label: '政治信息', value: '政治信息' },
{ label: '教育背景', value: '教育背景' },
{ label: '职务信息', value: '职务信息' },
{ label: '入职信息', value: '入职信息' },
{ label: '培训信息', value: '培训信息' },
{ label: '鉴定信息', value: '鉴定信息' },
{ label: '工作信息', value: '工作信息' },
{ label: '其他信息', value: '其他信息' },
];

View File

@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { Button, Input, Space, Tag } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
type OptionCreatorProps = {
options: { [key: string]: string } | { label: string; value: string }[] | undefined;
onChange: (newOptions: { label: string; value: string }[]) => void;
};
const OptionCreator: React.FC<OptionCreatorProps> = ({ options, onChange }) => {
const [localOptions, setLocalOptions] = useState<{ label: string; value: string }[]>([]);
const [inputValue, setInputValue] = useState('');
// 监听传入的 options 变化,更新本地状态
useEffect(() => {
let validOptions: { label: string; value: string }[] = [];
if (Array.isArray(options)) {
validOptions = options;
} else if (typeof options === 'object' && options !== null) {
validOptions = Object.entries(options).map(([value, label]) => ({ label, value }));
}
setLocalOptions(validOptions);
}, [options]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleAddOption = () => {
if (inputValue.trim()) {
const newOption = { label: inputValue, value: inputValue };
// 检查新选项是否已存在,避免重复添加
const isDuplicate = localOptions.some(option => option.value === newOption.value);
if (!isDuplicate) {
const newOptions = [...localOptions, newOption];
setLocalOptions(newOptions);
// 即时调用 onChange 通知父组件更新表单值
onChange(newOptions);
}
setInputValue('');
}
};
const handleDeleteOption = (value: string) => {
const newOptions = localOptions.filter(option => option.value !== value);
setLocalOptions(newOptions);
// 即时调用 onChange 通知父组件更新表单值
onChange(newOptions);
};
return (
<div>
<Space>
<Input
value={inputValue}
onChange={handleInputChange}
placeholder="输入新选项"
/>
<Button onClick={handleAddOption}></Button>
</Space>
<div style={{ marginTop: 16 }}>
{localOptions.map(option => (
<Tag
key={option.value}
closable
onClose={() => handleDeleteOption(option.value)}
icon={<DeleteOutlined />}
>
{option.label}
</Tag>
))}
</div>
</div>
);
};
export default OptionCreator;

View File

@ -0,0 +1,162 @@
import React, { useEffect } from 'react';
import { Button, Space, message } from 'antd';
import { useState } from 'react';
import { useStaff } from '@nice/client';
import { PlusOutlined } from '@ant-design/icons';
import DefaultFieldInitializer from './defaultFieldInitializer';
import StaffFieldTable from './staffFieldTable';
import StaffFieldModal from './staffFieldModal';
import { useWatch } from 'antd/es/form/Form';
import { Form } from 'antd';
const StaffFieldManage = () => {
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingField, setEditingField] = useState<any>(null);
const {
addCustomField,
updateCustomField,
deleteCustomField,
useCustomFields
} = useStaff();
const { data: fields, isLoading } = useCustomFields();
const optionFieldTypes = ['select', 'radio'];
const [showOptions, setShowOptions] = useState(false);
const typeValue = useWatch('type', form);
useEffect(() => {
setShowOptions(optionFieldTypes.includes(typeValue));
}, [typeValue, optionFieldTypes]);
const [isInitConfirmVisible, setIsInitConfirmVisible] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const checkFieldExists = (name: string, excludeId?: string) => {
return fields?.some(field =>
field.name === name && field.id !== excludeId
);
};
const handleAdd = () => {
setEditingField(null);
form.resetFields();
form.setFieldsValue({ options: [] });
setIsModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingField(record);
let optionsToSet = record.options;
if (typeof record.options === 'object' && record.options !== null && !Array.isArray(record.options)) {
optionsToSet = Object.entries(record.options).map(([value, label]) => ({ label, value }));
}
form.setFieldsValue({ ...record, options: optionsToSet });
setIsModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await deleteCustomField.mutateAsync(id);
message.success('字段删除成功');
} catch (error) {
message.error('字段删除失败');
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (checkFieldExists(values.name, editingField?.id)) {
message.error('字段标识已存在,请使用其他标识');
return;
}
setIsProcessing(true);
if (editingField) {
await updateCustomField.mutateAsync({
id: editingField.id,
...values,
});
message.success('字段更新成功');
} else {
await addCustomField.mutateAsync(values);
message.success('字段添加成功');
}
setIsModalVisible(false);
} catch (error) {
message.error('操作失败:' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsProcessing(false);
}
};
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请选择要删除的字段');
return;
}
try {
await Promise.all(
selectedRowKeys.map(async (id: string) => {
await deleteCustomField.mutateAsync(id);
})
);
message.success('批量删除成功');
setSelectedRowKeys([]);
} catch (error) {
message.error('批量删除失败');
}
};
return (
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold"></h2>
<Space>
<DefaultFieldInitializer
fields={fields}
isProcessing={isProcessing}
setIsProcessing={setIsProcessing}
isInitConfirmVisible={isInitConfirmVisible}
setIsInitConfirmVisible={setIsInitConfirmVisible}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
loading={isProcessing}
>
</Button>
</Space>
</div>
<StaffFieldTable
fields={fields}
isLoading={isLoading}
isProcessing={isProcessing}
selectedRowKeys={selectedRowKeys}
setSelectedRowKeys={setSelectedRowKeys}
handleEdit={handleEdit}
handleDelete={handleDelete}
handleBatchDelete={handleBatchDelete}
/>
<StaffFieldModal
form={form}
isModalVisible={isModalVisible}
setIsModalVisible={setIsModalVisible}
editingField={editingField}
isProcessing={isProcessing}
handleSubmit={handleSubmit}
optionFieldTypes={optionFieldTypes}
showOptions={showOptions}
/>
</div>
);
};
export default StaffFieldManage;

View File

@ -0,0 +1,107 @@
import React from 'react';
import { Form, Input, Select, InputNumber, Modal, message, FormInstance } from 'antd';
import OptionCreator from './optionCreator';
import { FieldTypeOptions, GroupOptions } from './defaultFields';
type StaffFieldModalProps = {
form: FormInstance;
isModalVisible: boolean;
setIsModalVisible: (visible: boolean) => void;
editingField: any;
isProcessing: boolean;
handleSubmit: () => Promise<void>;
optionFieldTypes: string[];
showOptions: boolean;
};
const StaffFieldModal: React.FC<StaffFieldModalProps> = ({
form,
isModalVisible,
setIsModalVisible,
editingField,
isProcessing,
handleSubmit,
optionFieldTypes,
showOptions
}) => {
return (
<Modal
title={editingField ? '编辑字段' : '添加字段'}
open={isModalVisible}
onOk={handleSubmit}
onCancel={() => setIsModalVisible(false)}
confirmLoading={isProcessing}
>
<Form
form={form}
layout="vertical"
>
<Form.Item
name="name"
label="字段标识"
rules={[
{ required: true, message: '请输入字段标识' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9]*$/, message: '字段标识必须以字母开头,只能包含字母和数字' }
]}
>
<Input placeholder="请输入字段标识phoneNumber" />
</Form.Item>
<Form.Item
name="label"
label="字段名称"
rules={[{ required: true, message: '请输入字段名称' }]}
>
<Input placeholder="请输入字段名称,如:手机号码" />
</Form.Item>
<Form.Item
name="type"
label="字段类型"
rules={[{ required: true, message: '请选择字段类型' }]}
>
<Select options={FieldTypeOptions} />
</Form.Item>
<Form.Item
name="options"
label="字段选项"
hidden={!showOptions}
>
<OptionCreator
options={form.getFieldValue('options') || []}
onChange={(newOptions) => form.setFieldsValue({ options: newOptions })}
/>
</Form.Item>
<Form.Item
name="required"
label="是否必填"
>
<Select
options={[
{ label: '是', value: true },
{ label: '否', value: false },
]}
/>
</Form.Item>
<Form.Item
name="group"
label="字段分组"
>
<Select options={GroupOptions} allowClear />
</Form.Item>
<Form.Item
name="order"
label="显示顺序"
>
<InputNumber min={0} />
</Form.Item>
</Form>
</Modal>
);
};
export default StaffFieldModal;

View File

@ -0,0 +1,123 @@
import React from 'react';
import { Table, Space, Button, Popconfirm, message } from 'antd';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { FieldTypeOptions } from './defaultFields';
type StaffFieldTableProps = {
fields: any[] | undefined;
isLoading: boolean;
isProcessing: boolean;
selectedRowKeys: string[];
setSelectedRowKeys: (keys: string[]) => void;
handleEdit: (record: any) => void;
handleDelete: (id: string) => Promise<void>;
handleBatchDelete: () => Promise<void>;
};
const checkFieldExists = (name: string, excludeId?: string, fields?: any[]) => {
return fields?.some(field =>
field.name === name && field.id !== excludeId
);
};
const StaffFieldTable: React.FC<StaffFieldTableProps> = ({
fields,
isLoading,
isProcessing,
selectedRowKeys,
setSelectedRowKeys,
handleEdit,
handleDelete,
handleBatchDelete
}) => {
const columns = [
{
title: '字段名称',
dataIndex: 'label',
key: 'label',
},
{
title: '字段标识',
dataIndex: 'name',
key: 'name',
},
{
title: '字段类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => FieldTypeOptions.find(t => t.value === type)?.label,
},
{
title: '字段选项',
dataIndex: 'options',
key: 'options',
render: (options) => {
const validOptions = Array.isArray(options) ? options : [];
return validOptions.map(option => option.label).join(', ');
},
},
{
title: '必填',
dataIndex: 'required',
key: 'required',
render: (required: boolean) => required ? '是' : '否',
},
{
title: '分组',
dataIndex: 'group',
key: 'group',
},
{
title: '操作',
key: 'action',
render: (_: any, record: any) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定要删除这个字段吗?"
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
const rowSelection = {
selectedRowKeys,
onChange: (keys: string[]) => {
setSelectedRowKeys(keys);
},
};
return (
<>
<Table
columns={columns}
dataSource={fields}
loading={isLoading || isProcessing}
rowKey="id"
rowSelection={rowSelection}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleBatchDelete}
loading={isProcessing}
>
</Button>
</>
);
};
export default StaffFieldTable;

View File

@ -58,6 +58,7 @@ const items = [
[
getItem("部门设置", "/admin/department", null, null, null),
getItem("角色设置", "/admin/role", null, null, null),
getItem("人员信息管理", "/admin/staffinfo", null, null, null),
],
null,
),

View File

@ -4,7 +4,6 @@ import { useCallback, useEffect} from "react";
import _ from "lodash";
import { useMainContext } from "../layout/MainProvider";
import StaffTable from "./stafftable/page";
import StaffModal from "./staffmodal/page";
export default function StaffMessage() {
const { form, formValue, setFormValue, setVisible, setSearchValue, editingRecord } = useMainContext();
@ -34,7 +33,6 @@ export default function StaffMessage() {
return (
<>
<StaffTable />
<StaffModal />
</>
);
}

View File

@ -5,7 +5,7 @@ import { ColDef, ColGroupDef } from '@ag-grid-community/core';
import { SetFilterModule } from '@ag-grid-enterprise/set-filter';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import { areaOptions } from '@web/src/app/main/staffinformation/area-options';
import { areaOptions } from '@web/src/app/main/staffinfo_write/area-options';
import type { CascaderProps } from 'antd/es/cascader';
import { utils, writeFile, read } from 'xlsx';
import { Modal, Input, Button, Switch, Upload, message } from 'antd';
@ -655,18 +655,10 @@ export default function StaffTable() {
>
{!isLoading && (
<div className="flex items-center gap-4 mb-2">
<Button
onClick={handleConfirm}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Excel
</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-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
</Button>
<Button
onClick={handleResetFilters}
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"

View File

@ -0,0 +1,129 @@
"use client";
import { Button, Form, Input, Select, DatePicker, Radio, message, Modal, Cascader, InputNumber } from "antd";
import { useState, useMemo } from "react";
import { useStaff } from "@nice/client";
import { areaOptions } from './area-options';
const { TextArea } = Input;
const StaffInfoWrite = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { create, useCustomFields } = useStaff();
const { data: fields, isLoading: fieldsLoading } = useCustomFields();
// 按分组组织字段
const fieldGroups = useMemo(() => {
if (!fields) return {};
return fields.reduce((groups: any, field: any) => {
const group = field.group || '其他信息';
if (!groups[group]) {
groups[group] = [];
}
groups[group].push(field);
return groups;
}, {});
}, [fields]);
const renderField = (field: any) => {
switch (field.type) {
case 'text':
return <Input />;
case 'number':
return <InputNumber />;
case 'date':
return <DatePicker />;
case 'select':
// 检查 field.options 是否存在且有数据
if (field.options && field.options.length > 0) {
return <Select options={field.options} />;
}
return <Input placeholder="选项数据缺失" />;
case 'radio':
// 检查 field.options 是否存在且有数据
if (field.options && field.options.length > 0) {
return <Radio.Group options={field.options} />;
}
return <Input placeholder="选项数据缺失" />;
case 'textarea':
return <Input.TextArea />;
default:
return <Input />;
}
};
const onFinish = async (values: any) => {
try {
setLoading(true);
const formattedValues = {
...values,
birthplace: values.birthplace?.join('/'),
};
await create.mutateAsync({
data: formattedValues
});
message.success("信息提交成功");
} catch (error) {
console.error('提交出错:', error);
message.error("提交失败,请重试");
} finally {
setLoading(false);
}
};
if (fieldsLoading) {
return <div>...</div>;
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6"></h1>
{/* 信息填报表单 */}
<Form
form={form}
layout="vertical"
onFinish={onFinish}
className="space-y-6 mt-6"
>
{Object.entries(fieldGroups).map(([groupName, groupFields]) => (
<div key={groupName} className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">{groupName}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{groupFields.map((field: any) => (
<Form.Item
key={field.id}
label={field.label}
name={field.name}
rules={[
{
required: field.required,
message: `请输入${field.label}`,
},
]}
>
{renderField(field)}
</Form.Item>
))}
</div>
</div>
))}
<div className="flex justify-end space-x-4">
<Button onClick={() => form.resetFields()}></Button>
<Button
type="primary"
htmlType="submit"
loading={loading}
>
</Button>
</div>
</Form>
</div>
);
};
export default StaffInfoWrite;

View File

@ -1,129 +0,0 @@
import { api, useStaff } from "@nice/client";
import { useMainContext } from "../../layout/MainProvider";
import toast from "react-hot-toast";
import { Button, Form, Input, Modal, Select } from "antd";
import { useEffect } from "react";
import TrainContentTreeSelect from "@web/src/components/models/trainContent/train-content-tree-select";
import DepartmentChildrenSelect from "@web/src/components/models/department/department-children-select";
export default function StaffModal() {
const { data: traincontents} = api.trainSituation.findMany.useQuery({
select:{
id: true,
trainContent:{
select:{
id: true,
title: true,
type: true,
}
},
}
});
// useEffect(() => {
// traincontents?.forEach((situation)=>{
// console.log(situation.id);
// });
// }, [traincontents]);
const { form, formValue, setVisible, visible, editingRecord, setEditingRecord } = useMainContext()
const { create, update } = useStaff();
const handleOk = async () => {
const values = await form.getFieldsValue();
// console.log(values);
// console.log(values.position);
try {
if (editingRecord && editingRecord.id) {
await update.mutateAsync({
where: { id: editingRecord.id },
data: {
username: values.username,
department: {
connect: { id: values.deptId }
},
rank: values.rank,
type: values.type,
showname: values.username,
updatedAt: new Date()
} ,
});
// console.log(result);
} else {
const result = await create.mutateAsync(
{
data: {
username: values.username,
department: {
connect: { id: values.deptId }
},
createdAt: new Date(),
showname: values.username,
rank: values.rank,
type: values.type,
} as any,
}
) ;
}
toast.success("保存成功");
setVisible(false);
setEditingRecord(null);
} catch (error) {
toast.error("保存失败");
throw error;
}
};
const handleCancel = () => {
setVisible(false);
setEditingRecord(null);
form.resetFields();
};
useEffect(() => {
if (visible&&editingRecord) {
form.setFieldsValue(editingRecord);
}
}, [visible,editingRecord]);
return (
<>
<Modal
title="编辑员工信息"
visible={visible}
onOk={handleOk}
onCancel={handleCancel}
>
<Form
form={form}
initialValues={formValue}
>
<Form.Item
name={"username"}
label="姓名"
>
<Input className="rounded-lg" />
</Form.Item>
<Form.Item
name={"deptId"}
label="部门"
>
<DepartmentChildrenSelect></DepartmentChildrenSelect>
</Form.Item>
<Form.Item
name={["type"]}
label="人员类别"
>
<Input className="rounded-lg" />
</Form.Item>
<Form.Item
name={["rank"]}
label="衔职"
>
<Input className="rounded-lg" />
</Form.Item>
</Form>
</Modal>
{/* <Button onClick={() =>{console.log(traincontents);}}>TEST</Button> */}
</>
)
}

View File

@ -15,6 +15,7 @@ import WithAuth from "../components/utils/with-auth";
import { CustomRouteObject } from "./types";
import StaffPage from "../app/admin/staff/page";
import AdminLayout from "../components/layout/admin/AdminLayout";
import StaffFieldManage from "../app/admin/staffinfo-manage/staffFieldManage";
export const adminRoute: CustomRouteObject = {
path: "admin",
@ -120,5 +121,11 @@ export const adminRoute: CustomRouteObject = {
},
},
},
{
path: "staffinfo",
name: "人员信息管理",
icon: <UserOutlined />,
element: <StaffFieldManage></StaffFieldManage>,
},
],
};

View File

@ -8,12 +8,12 @@ import {
import ErrorPage from "../app/error";
import LoginPage from "../app/login";
import HomePage from "../app/main/home/page";
import StaffMessage from "../app/main/staffpage/page";
import StaffMessage from "../app/main/staffinfo_show/page";
import MainLayout from "../app/main/layout/MainLayout";
import DailyPage from "../app/main/daily/page";
import Dashboard from "../app/main/home/page";
import WeekPlanPage from "../app/main/plan/weekplan/page";
import StaffInformation from "../app/main/staffinformation/page";
import StaffInformation from "../app/main/staffinfo_write/page";
import DeptSettingPage from "../app/main/admin/deptsettingpage/page";
import { adminRoute } from "./admin-route";
import AdminLayout from "../components/layout/admin/AdminLayout";

View File

@ -4,7 +4,37 @@ import { useQueryClient } from "@tanstack/react-query";
import { ObjectType, Staff } from "@nice/common";
import { findQueryData } from "../utils";
import { CrudOperation, emitDataChange } from "../../event";
export function useStaff() {
export interface CustomField {
name: string;
label?: string;
type: string;
required?: boolean;
order?: number;
options?: any;
group?: string;
}
export interface CustomFieldValue {
staffId: string;
fieldId: string;
value?: string;
}
interface StaffHookReturn {
create: ReturnType<typeof api.staff.create.useMutation>;
update: ReturnType<typeof api.staff.update.useMutation>;
softDeleteByIds: ReturnType<typeof api.staff.softDeleteByIds.useMutation>;
getStaff: (key: string) => Staff | undefined;
updateUserDomain: ReturnType<typeof api.staff.updateUserDomain.useMutation>;
addCustomField: ReturnType<typeof api.staff.addCustomField.useMutation>;
updateCustomField: ReturnType<typeof api.staff.updateCustomField.useMutation>;
deleteCustomField: ReturnType<typeof api.staff.deleteCustomField.useMutation>;
useCustomFields: () => ReturnType<typeof api.staff.getCustomFields.useQuery>;
setCustomFieldValue: ReturnType<typeof api.staff.setCustomFieldValue.useMutation>;
}
export function useStaff(): StaffHookReturn {
const queryClient = useQueryClient();
const queryKey = getQueryKey(api.staff);
@ -19,8 +49,9 @@ export function useStaff() {
},
});
const updateUserDomain = api.staff.updateUserDomain.useMutation({
onSuccess: async (result) => {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey });
emitDataChange(ObjectType.STAFF, result as any, CrudOperation.UPDATED);
},
});
const update = api.staff.update.useMutation({
@ -39,14 +70,45 @@ export function useStaff() {
queryClient.invalidateQueries({ queryKey });
},
});
const addCustomField = api.staff.addCustomField.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getQueryKey(api.staff.getCustomFields) });
},
});
const updateCustomField = api.staff.updateCustomField.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getQueryKey(api.staff.getCustomFields) });
},
});
const deleteCustomField = api.staff.deleteCustomField.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getQueryKey(api.staff.getCustomFields) });
},
});
const useCustomFields = () => {
return api.staff.getCustomFields.useQuery(undefined, {
staleTime: 1000 * 60 * 5, // 5分钟缓存
});
};
const setCustomFieldValue = api.staff.setCustomFieldValue.useMutation({
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey });
},
});
const getStaff = (key: string) => {
return findQueryData<Staff>(queryClient, api.staff, key);
};
return {
create,
update,
softDeleteByIds,
getStaff,
updateUserDomain,
addCustomField,
updateCustomField,
deleteCustomField,
useCustomFields,
setCustomFieldValue,
};
}

View File

@ -420,6 +420,41 @@ model Department {
@@map("department")
}
model StaffField {
id String @id @default(cuid())
name String @unique // 字段名称
label String? // 字段显示名称
type String // 字段类型 (text, number, date, select 等)
required Boolean? @default(false)
order Float? // 显示顺序
options Json? // 对于选择类型字段的可选值
group String? // 字段分组 (基本信息、工作信息等)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([group])
@@index([order])
@@map("staff_field")
StaffFieldValue StaffFieldValue[]
}
model StaffFieldValue {
id String @id @default(cuid())
staffId String @map("staff_id")
fieldId String @map("field_id")
value String? // 字段值
staff Staff @relation(fields: [staffId], references: [id])
field StaffField @relation(fields: [fieldId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([staffId, fieldId])
@@index([staffId])
@@index([fieldId])
@@map("staff_field_value")
}
model Staff {
// 基础信息
id String @id @default(cuid())
@ -510,6 +545,9 @@ model Staff {
deletedAt DateTime? @map("deleted_at")
absent Boolean? @default(false) @map("absent")
// 添加自定义字段值关联
fieldValues StaffFieldValue[]
@@index([officerId])
@@index([deptId])
@@index([domainId])