Files
performance-evaluation-system/frontend/src/pages/employee/PerformanceForm.tsx
2026-04-11 11:51:54 +08:00

428 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import {
Form,
Input,
InputNumber,
Button,
Card,
Divider,
Space,
Typography,
message,
Tag,
Row,
Col,
Alert,
DatePicker,
Result,
Spin,
} from 'antd';
import { SaveOutlined, SendOutlined, LoadingOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useAuth } from '../../context/AuthContext';
import { performanceApi, PerformanceSubmitDTO } from '../../api/performance';
import { ALL_PERFORMANCE_ITEMS } from './performanceItems';
import dayjs, { Dayjs } from 'dayjs';
const { Title, Text } = Typography;
const { TextArea } = Input;
interface FormValues {
assessmentMonth: Dayjs;
workSummary: string;
leave: number;
late: number;
absent: number;
lackCard: number;
attendanceRemark?: string;
items: Array<{
userContent: string;
selfScore: number;
evidence?: string;
}>;
}
const PerformanceForm: React.FC = () => {
const { userInfo } = useAuth();
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
const [saving, setSaving] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [aiWaiting, setAiWaiting] = useState(false);
const [aiDone, setAiDone] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
// 清理轮询定时器
useEffect(() => {
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, []);
// 轮询 AI 结果
const pollAIResult = (perfId: number) => {
setAiWaiting(true);
let attempts = 0;
const maxAttempts = 40; // 最多轮询 40 次(约 2 分钟)
pollTimerRef.current = setInterval(async () => {
attempts++;
try {
const result = await performanceApi.checkAIResult(perfId);
if (result.done) {
clearInterval(pollTimerRef.current!);
setAiWaiting(false);
setAiDone(true);
} else if (attempts >= maxAttempts) {
clearInterval(pollTimerRef.current!);
setAiWaiting(false);
setAiDone(true); // 超时也跳转,让用户去历史记录查看
}
} catch {
// 忽略轮询错误,继续重试
}
}, 3000); // 每 3 秒轮询一次
};
// 获取本月作为默认值
const getPreviousMonth = (): Dayjs => {
return dayjs();
};
const buildDTO = (values: FormValues, status: 'draft' | 'submitted'): PerformanceSubmitDTO => ({
month: values.assessmentMonth.format('YYYY-MM'),
status,
workSummary: values.workSummary || '',
attendance: {
leave: values.leave ?? 0,
late: values.late ?? 0,
absent: values.absent ?? 0,
lackCard: values.lackCard ?? 0,
remark: values.attendanceRemark,
},
performanceItems: ALL_PERFORMANCE_ITEMS.map((item, idx) => ({
itemName: item.itemName,
weight: item.weight,
userContent: values.items?.[idx]?.userContent || '',
selfScore: values.items?.[idx]?.selfScore ?? 0,
evidence: values.items?.[idx]?.evidence,
})),
});
const handleSave = async () => {
const values = form.getFieldsValue();
setSaving(true);
try {
await performanceApi.submit(buildDTO(values, 'draft'));
message.success('草稿已暂存');
} catch (err: any) {
message.error(err?.response?.data?.message || '暂存失败,请重试');
} finally {
setSaving(false);
}
};
const handleSubmit = async (values: FormValues) => {
setSubmitting(true);
try {
const result = await performanceApi.submit(buildDTO(values, 'submitted'));
setSubmitted(true);
form.resetFields();
// 提交成功后开始轮询 AI 结果
if (result?.perfId) {
pollAIResult(result.perfId);
}
} catch (err: any) {
message.error(err?.response?.data?.message || '提交失败,请重试');
} finally {
setSubmitting(false);
}
};
if (submitted) {
// AI 分析完成
if (aiDone) {
return (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="AI 分析完成!"
subTitle="绩效已提交AI 评分已生成,可前往历史记录查看详情。"
style={{ margin: 24 }}
extra={
<Space>
<Button type="primary" onClick={() => { setSubmitted(false); setAiDone(false); }}>
</Button>
</Space>
}
/>
);
}
// AI 分析中
if (aiWaiting) {
return (
<div style={{ textAlign: 'center', padding: '80px 24px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />} />
<div style={{ marginTop: 24 }}>
<Typography.Title level={4}>AI </Typography.Title>
<Typography.Text type="secondary">
AI 30 1 ...
</Typography.Text>
</div>
</div>
);
}
// 提交成功但还未开始轮询(过渡状态)
return (
<Alert
type="success"
message="提交成功"
description="您的绩效已成功提交AI 评分将在后台自动完成,管理层将在审核期内完成审核。"
showIcon
style={{ margin: 24 }}
action={
<Button onClick={() => setSubmitted(false)}></Button>
}
/>
);
}
return (
<div style={{ padding: isMobile ? 8 : 24, maxWidth: 900, margin: '0 auto' }}>
<Title level={isMobile ? 4 : 3}></Title>
{/* 基础信息 */}
<Card style={{ marginBottom: 16 }}>
{isMobile ? (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div><Text type="secondary"></Text><Text strong>{userInfo?.name}</Text></div>
<div><Text type="secondary"></Text><Text strong>{userInfo?.department}</Text></div>
<div><Text type="secondary"></Text><Text strong>{userInfo?.position}</Text></div>
</Space>
) : (
<Row gutter={24}>
<Col span={8}><Text type="secondary"></Text><Text strong>{userInfo?.name}</Text></Col>
<Col span={8}><Text type="secondary"></Text><Text strong>{userInfo?.department}</Text></Col>
<Col span={8}><Text type="secondary"></Text><Text strong>{userInfo?.position}</Text></Col>
</Row>
)}
</Card>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
assessmentMonth: getPreviousMonth(),
leave: 0, late: 0, absent: 0, lackCard: 0,
items: ALL_PERFORMANCE_ITEMS.map(() => ({ selfScore: 0 })),
}}
>
{/* 考核月份选择 */}
<Card style={{ marginBottom: 16 }}>
<Form.Item
name="assessmentMonth"
label="考核月份"
rules={[{ required: true, message: '请选择考核月份' }]}
>
<DatePicker
picker="month"
format="YYYY-MM"
placeholder="请选择考核月份"
style={{ width: 200 }}
disabledDate={(current) => {
// 不能选择未来的月份
return current && current > dayjs().endOf('month');
}}
/>
</Form.Item>
</Card>
{/* 考核指标 */}
<Card title="考核指标填写" style={{ marginBottom: 16 }}>
<Alert
type="info"
message="请逐项填写各考核指标的完成情况描述和自评分数0-100分"
style={{ marginBottom: 16 }}
showIcon
/>
{/* 业务素质 */}
<Title level={5}>
<Tag color="blue"> 70%</Tag>
</Title>
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'business').map((item, idx) => (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag color="geekblue"> {item.weight} </Tag>
</Space>
}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{item.description}
</Text>
<Row gutter={16}>
<Col xs={24} md={16}>
<Form.Item
name={['items', idx, 'userContent']}
label="完成情况描述"
rules={[{ required: true, message: '请填写完成情况描述' }]}
>
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item
name={['items', idx, 'selfScore']}
label="自评分数"
rules={[{ required: true, message: '请填写自评分' }]}
>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
<Input placeholder="链接或说明" />
</Form.Item>
</Col>
</Row>
</Card>
))}
<Divider />
{/* 综合素质 */}
<Title level={5}>
<Tag color="green"> 30%</Tag>
</Title>
{ALL_PERFORMANCE_ITEMS.filter(i => i.category === 'comprehensive').map((item) => {
const idx = ALL_PERFORMANCE_ITEMS.findIndex(x => x.itemName === item.itemName);
return (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag color="green"> {item.weight} </Tag>
</Space>
}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{item.description}
</Text>
<Row gutter={16}>
<Col xs={24} md={16}>
<Form.Item
name={['items', idx, 'userContent']}
label="完成情况描述"
rules={[{ required: true, message: '请填写完成情况描述' }]}
>
<TextArea rows={2} placeholder="请描述本月该项指标的完成情况" />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item
name={['items', idx, 'selfScore']}
label="自评分数"
rules={[{ required: true, message: '请填写自评分' }]}
>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} md={4}>
<Form.Item name={['items', idx, 'evidence']} label="佐证材料(可选)">
<Input placeholder="链接或说明" />
</Form.Item>
</Col>
</Row>
</Card>
);
})}
</Card>
{/* 考勤数据 */}
<Card title="考勤数据" style={{ marginBottom: 16 }}>
<Alert
type="info"
message="考勤满分 10 分,事假每天扣 5 分,迟到/缺卡每次扣 2 分,最低 0 分"
style={{ marginBottom: 16 }}
showIcon
/>
<Row gutter={16}>
<Col xs={12} sm={6}>
<Form.Item name="leave" label="事假(天)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={6}>
<Form.Item name="late" label="迟到(次)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={6}>
<Form.Item name="absent" label="旷工(天)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={6}>
<Form.Item name="lackCard" label="缺卡(次)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="attendanceRemark" label="考勤备注(可选)">
<TextArea rows={2} placeholder="如有特殊情况请说明" />
</Form.Item>
</Card>
{/* 工作汇总 */}
<Card title="工作汇总" style={{ marginBottom: 16 }}>
<Form.Item
name="workSummary"
label="工作汇总"
rules={[{ required: true, message: '请填写工作汇总' }, { min: 10, message: '工作汇总至少填写 10 个字' }]}
>
<TextArea
rows={5}
placeholder="请填写本月工作总结,包括主要工作内容、成果、遇到的问题及解决方案等"
/>
</Form.Item>
</Card>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 16 }}>
<Button
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
size="large"
style={isMobile ? { flex: 1 } : {}}
>
稿
</Button>
<Button
type="primary"
icon={<SendOutlined />}
htmlType="submit"
loading={submitting}
size="large"
style={isMobile ? { flex: 1 } : {}}
>
</Button>
</div>
</Form>
</div>
);
};
export default PerformanceForm;