first commit
This commit is contained in:
427
frontend/src/pages/employee/PerformanceForm.tsx
Normal file
427
frontend/src/pages/employee/PerformanceForm.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user