Files
cn-rdms-web/src/views/personal-center/work-report/monthly/modules/approval-page.vue

1780 lines
43 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/* eslint-disable no-useless-escape */
import { computed, reactive, ref } from 'vue';
import dayjs from 'dayjs';
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
type WorkReportStructuredSection,
type WorkReportStructuredTask,
getStructuredSections
} from '../../shared/types';
import { formatPeriodLabel } from '../../shared/types';
const props = defineProps<{
reportType: string;
period: string;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
baseInfo?: Api.WorkReport.Monthly.MonthlyReport | null;
model: Api.WorkReport.Monthly.MonthlyReportSaveParams;
approvalModel: Api.WorkReport.Monthly.MonthlyReportApproveParams;
}>();
const emit = defineEmits<{
back: [];
requestApprove: [];
requestReject: [];
changeApproval: [payload: Api.WorkReport.Monthly.MonthlyReportApproveParams];
viewAudit: [];
}>();
interface ReviewItem {
workItem: string;
days: number;
hours?: number;
content: string;
contentHtml: string;
contentSections?: StructuredSection[];
reflection: string;
removable?: boolean;
}
interface PlanItem {
workItem: string;
target: string;
targetHtml: string;
targetSections?: StructuredSection[];
supportNeed: string;
removable?: boolean;
}
interface StructuredTask {
title: string;
priority?: string;
progress?: number;
hours?: number;
}
interface StructuredSection {
category: string;
tasks: StructuredTask[];
}
const formTitle = computed(() => `${props.reportType || '个人月报'}审批页`);
const EMPTY_TEXT = '';
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
const todayText = computed(() => dayjs().format('YYYY-MM-DD'));
const auditDialogVisible = ref(false);
const auditForm = ref({
conclusion: '通过',
opinion: ''
});
/** 必填字段校验错误提示 */
const performanceError = ref('');
const meetingDateError = ref('');
function patchApproval<K extends keyof Api.WorkReport.Monthly.MonthlyReportApproveParams>(
key: K,
value: Api.WorkReport.Monthly.MonthlyReportApproveParams[K]
) {
emit('changeApproval', {
...props.approvalModel,
[key]: value
});
}
const feedbackForm = reactive({
get strengthDesc() {
return props.approvalModel.strengthDesc || '';
},
set strengthDesc(value: string) {
patchApproval('strengthDesc', value);
},
get strengthExample() {
return props.approvalModel.strengthExample || '';
},
set strengthExample(value: string) {
patchApproval('strengthExample', value);
},
get weaknessDesc() {
return props.approvalModel.weaknessDesc || '';
},
set weaknessDesc(value: string) {
patchApproval('weaknessDesc', value);
},
get weaknessExample() {
return props.approvalModel.weaknessExample || '';
},
set weaknessExample(value: string) {
patchApproval('weaknessExample', value);
},
get improvementSuggestion() {
return props.approvalModel.improvementSuggestion || '';
},
set improvementSuggestion(value: string) {
patchApproval('improvementSuggestion', value);
}
});
const performanceForm = reactive({
get score() {
return props.approvalModel.performanceResult || '';
},
set score(value: string) {
patchApproval('performanceResult', value);
}
});
/** 绩效分数输入限制0-100最多一位小数 */
function handlePerformanceScoreInput(value: string) {
// 只允许数字和一个小数点
let filtered = value.replace(/[^\d.]/g, '');
// 只保留第一个小数点
const dotIndex = filtered.indexOf('.');
if (dotIndex !== -1) {
filtered = filtered.slice(0, dotIndex + 1) + filtered.slice(dotIndex + 1).replace(/\./g, '');
}
// 最多一位小数
const match = filtered.match(/^(\d+)(?:\.(\d{0,1}))?/);
if (match) {
filtered = match[1] + (match[2] !== undefined ? `.${match[2]}` : '');
}
// 限制 0-100
if (filtered !== '' && filtered !== '.') {
const num = Number(filtered);
if (num > 100) {
filtered = '100';
}
}
// 防止前导零(如 007但允许 0 和 0.x
if (/^0\d/.test(filtered) && !filtered.startsWith('0.')) {
filtered = filtered.replace(/^0+/, '') || '0';
}
if (filtered !== value) {
performanceForm.score = filtered;
}
// 清除错误提示
if (filtered) {
performanceError.value = '';
}
}
/** 绩效分数失焦校验 */
function handlePerformanceScoreBlur() {
if (!performanceForm.score) {
performanceError.value = '请输入绩效考核结果';
}
}
const mainForm = reactive({
get reporter() {
return props.baseInfo?.reporterName || '--';
},
get deptName() {
return props.baseInfo?.reporterDeptName || '--';
},
get postName() {
return props.baseInfo?.reporterPostName || '--';
},
get supervisor() {
return props.baseInfo?.supervisorName || '--';
},
get meetingDate() {
return props.approvalModel.meetingDate || '';
},
set meetingDate(value: string) {
patchApproval('meetingDate', value);
}
});
/** 面谈时间失焦校验 */
function handleMeetingDateBlur() {
if (!mainForm.meetingDate) {
meetingDateError.value = '请选择面谈时间';
} else {
meetingDateError.value = '';
}
}
const signatureForm = reactive({
get employeeSign() {
return props.approvalModel.employeeSignName || '';
},
set employeeSign(value: string) {
patchApproval('employeeSignName', value);
},
get employeeDate() {
return props.approvalModel.employeeSignedDate || todayText.value;
},
set employeeDate(value: string) {
patchApproval('employeeSignedDate', value);
},
get supervisorSign() {
return props.approvalModel.supervisorSignName || '';
},
set supervisorSign(value: string) {
patchApproval('supervisorSignName', value);
},
get supervisorDate() {
return props.approvalModel.supervisorSignedDate || todayText.value;
},
set supervisorDate(value: string) {
patchApproval('supervisorSignedDate', value);
}
});
function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
return {
title: task.title || '',
priority: normalizePriorityCode(task.priority),
progress: typeof task.progress === 'number' ? task.progress : undefined,
hours: typeof task.hours === 'number' ? task.hours : undefined
};
}
function normalizePriorityCode(value?: string | number | null) {
const text = String(value ?? '').trim();
if (!text) return undefined;
const matchedByValue = priorityDictData.value.find(item => String(item.value) === text);
if (matchedByValue) return String(matchedByValue.value);
const matchedByLabel = priorityDictData.value.find(item => item.label === text);
if (matchedByLabel) return String(matchedByLabel.value);
const priorityLabelMatch = text.match(/^P(\d+)$/iu);
if (priorityLabelMatch) return priorityLabelMatch[1];
return text;
}
function resolvePriorityLabel(value?: string | number | null) {
const priorityCode = normalizePriorityCode(value);
if (!priorityCode) return '';
return getPriorityLabel(priorityCode, {
fallback: /^P\d+$/iu.test(priorityCode) ? priorityCode : `P${priorityCode}`
});
}
function resolveTaskItemTypeLabel(value: string) {
return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
}
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return {
category: resolveTaskItemTypeLabel(section.category || '工作内容'),
tasks: section.tasks.map(normalizeTask)
};
}
function mergeSectionsByCategory(sections: StructuredSection[]) {
const sectionMap = new Map<string, StructuredSection>();
sections.forEach(section => {
const category = section.category || '未分类';
const existing = sectionMap.get(category);
if (existing) {
existing.tasks.push(...section.tasks);
} else {
sectionMap.set(category, { category, tasks: [...section.tasks] });
}
});
return Array.from(sectionMap.values());
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
const metrics = [
task.priority ? escapeHtml(resolvePriorityLabel(task.priority)) : '',
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
].filter(Boolean);
return metrics.length
? `<span class="rich-inline-metrics" contenteditable="false">${metrics.join(' · ')}</span>`
: '';
}
function createStructuredHtml(sections: StructuredSection[], showHours = false) {
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, '');
const splitPunctuation = (text: string) => {
const rawTitle = stripOrderPrefix(text);
const punctuation = rawTitle.match(/[。?!?]$/)?.[0] || '';
return {
title: punctuation ? rawTitle.slice(0, -1) : rawTitle,
punctuation
};
};
return sections
.map(section => {
const tasks = section.tasks
.map((task, taskIndex) => {
const { title, punctuation } = splitPunctuation(task.title);
return `<span class="rich-section-task"><span class="rich-section-task-title">${taskIndex + 1}.${escapeHtml(title)}</span>${createTaskMetrics(task, showHours)}${escapeHtml(punctuation)}</span>`;
})
.join('');
return `
<div class="rich-section">
<div class="rich-section-title">${escapeHtml(resolveTaskItemTypeLabel(section.category.trim()))}</div>
${tasks ? `<div class="rich-section-tasks">${tasks}</div>` : ''}
</div>
`;
})
.join('');
}
function normalizeEditorText(value: string) {
return value
.replace(/\u00A0/g, ' ')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n');
}
function stripTaskOrderPrefix(value: string) {
return value.trim().replace(/^\d+[..、]\s*/, '');
}
function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTask>) {
const metricParts = metricsText
.split(/[\/、·]/u)
.map(item => item.trim())
.filter(Boolean);
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
const priority = normalizePriorityCode(priorityText || fallback?.priority);
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/iu)?.[1];
return {
priority,
progress: progressText === undefined ? fallback?.progress : Number(progressText),
hours: hoursText === undefined ? fallback?.hours : Number(hoursText)
};
}
function createStructuredSectionsFromText(
text: string,
category: string,
defaultTask?: Partial<StructuredTask>
): StructuredSection[] {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
const groupedSections: StructuredSection[] = [];
lines.forEach(line => {
const structuredMatch = line.match(/^(.+?)\s*[-–—]\s*(.+?)\s*[(]([^)]*)[)]$/u);
if (!structuredMatch) return;
const [, rawCategory, rawTitle, metricsText] = structuredMatch;
const categoryText = rawCategory.trim();
const title = rawTitle.trim();
if (!categoryText || !title) return;
let section = groupedSections.find(item => item.category === categoryText);
if (!section) {
section = { category: categoryText, tasks: [] };
groupedSections.push(section);
}
section.tasks.push({
...defaultTask,
title,
...resolveTaskMetrics(metricsText, defaultTask)
});
});
if (groupedSections.length) return groupedSections;
const looksLikeTaskLine = (value: string) => /^\d+[.]\s*/u.test(value);
const hasNumberedTask = lines.some(looksLikeTaskLine);
const sectionCategory = hasNumberedTask && !looksLikeTaskLine(lines[0]) ? lines[0] : category;
const taskSourceText = (sectionCategory === lines[0] ? lines.slice(1) : lines).join(' ');
const taskParts = hasNumberedTask
? taskSourceText
.split(/(?=\d+[..、]\s*)/u)
.map(item => item.trim())
.filter(Boolean)
: lines;
const tasks = taskParts
.map((item, index) => {
const normalizedTitle = stripTaskOrderPrefix(item);
const metricsText = normalizedTitle.match(/[(]([^)]*)[)]/u)?.[1] || '';
const title = normalizedTitle.replace(/[(][^)]*[)]/u, '').trim();
if (!title) return null;
return {
...(index === 0 ? defaultTask : undefined),
title,
...resolveTaskMetrics(metricsText, index === 0 ? defaultTask : undefined)
};
})
.filter(Boolean) as StructuredTask[];
if (!tasks.length) return [];
return [
{
category: sectionCategory,
tasks
}
];
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
const contentSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromText(item.contentText || '', item.itemTitle || '未分类', {
hours: typeof item.workHours === 'number' ? item.workHours : Number(item.workHours || 0) || undefined
});
const contentHtml = contentSections.length
? createStructuredHtml(contentSections, true)
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>') || EMPTY_TEXT;
return {
workItem: item.itemTitle || '未命名工作',
days: 0,
hours: item.workHours || 0,
content: item.contentText || EMPTY_TEXT,
contentHtml,
contentSections,
reflection: item.reflectionText || EMPTY_TEXT,
removable: false
};
}
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem): PlanItem {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
const targetSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromText(item.targetText || '', item.itemTitle || '未分类');
const targetHtml = targetSections.length
? createStructuredHtml(targetSections)
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT;
return {
workItem: item.itemTitle || '未命名计划',
target: item.targetText || EMPTY_TEXT,
targetHtml,
targetSections,
supportNeed: item.supportNeed || EMPTY_TEXT,
removable: false
};
}
const reviewItems = computed<ReviewItem[]>(() => {
return (props.model.reviewItems || []).map(toReviewItem);
});
const nextPlans = computed<PlanItem[]>(() => {
return (props.model.planItems || []).map(toPlanItem);
});
const totalHours = computed(() => {
const baseTotalWorkHours = Number(props.baseInfo?.totalWorkHours ?? 0);
if (Number.isFinite(baseTotalWorkHours) && baseTotalWorkHours > 0) return baseTotalWorkHours;
return (props.model.reviewItems || []).reduce((sum, item) => sum + Number(item.workHours || 0), 0);
});
const periodText = computed(() => formatPeriodLabel(props.model.periodLabel || props.period));
function formatTaskTitle(title: string) {
const rawTitle = title.trim().replace(/^\d+[.、.]\s*/, '');
return rawTitle.replace(/[。.!?]$/, '');
}
function getTaskPunctuation(title: string) {
const rawTitle = title.trim().replace(/^\d+[.、.]\s*/, '');
return rawTitle.match(/[。.!?]$/)?.[0] || '';
}
function submitAudit() {
// 校验必填字段
if (!performanceForm.score) {
performanceError.value = '请输入绩效考核结果';
}
if (!mainForm.meetingDate) {
meetingDateError.value = '请选择面谈时间';
}
if (!auditForm.value.conclusion) {
window.$message?.warning('请选择审批结论');
return;
}
if (!performanceForm.score || !mainForm.meetingDate) {
window.$message?.warning('请填写必填字段');
return;
}
patchApproval('employeeSignedDate', signatureForm.employeeDate);
patchApproval('supervisorSignedDate', signatureForm.supervisorDate);
patchApproval('reason', auditForm.value.opinion || auditForm.value.conclusion);
patchApproval('meetingDate', mainForm.meetingDate);
auditDialogVisible.value = false;
if (auditForm.value.conclusion === '不通过') {
emit('requestReject');
} else {
emit('requestApprove');
}
}
function openAuditDialog() {
Object.assign(auditForm.value, {
conclusion: '通过',
opinion: ''
});
auditDialogVisible.value = true;
}
</script>
<template>
<div class="card form-page">
<div class="section">
<div class="section-title">
<span>基础信息</span>
</div>
<div class="compose-grid">
<div class="field">
<label>姓名</label>
<ElInput v-model="mainForm.reporter" disabled />
</div>
<div class="field">
<label>部门/方向</label>
<ElInput v-model="mainForm.deptName" disabled />
</div>
<div class="field">
<label>岗位</label>
<ElInput v-model="mainForm.postName" disabled />
</div>
<div class="field">
<label>周期</label>
<ElInput :model-value="periodText" disabled />
</div>
<div class="field">
<label>直接上级</label>
<ElInput v-model="mainForm.supervisor" disabled />
</div>
<div class="field">
<label>
<span class="required-mark">*</span>
面谈时间
</label>
<div class="field-with-error">
<ElDatePicker
v-model="mainForm.meetingDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
@blur="handleMeetingDateBlur"
/>
<span v-if="meetingDateError" class="field-error">{{ meetingDateError }}</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<div class="section-title-left">
<span>当期重点工作回顾</span>
<span class="source-chip">{{ reviewItems.length }} 项工作</span>
<span class="source-chip"> {{ totalHours }}h</span>
</div>
</div>
<div class="review-grid layout-row">
<div v-if="!reviewItems.length">{{ EMPTY_TEXT }}</div>
<div v-for="(item, index) in reviewItems" :key="index" class="review-card">
<div class="review-index-cell">
<span class="row-index">{{ index + 1 }}</span>
</div>
<div class="review-name-cell">
<div class="work-title-line">
<strong>{{ item.workItem }}</strong>
<span> {{ item.hours }}h</span>
</div>
</div>
<div class="review-editor-grid">
<div class="field">
<label>具体工作内容及成果描述</label>
<div class="rich-editor readonly" v-html="item.contentHtml"></div>
</div>
<div class="field">
<label>工作感悟</label>
<div class="rich-editor readonly">{{ item.reflection || '' }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<span>当期工作反馈</span>
<ElTag type="info" size="small" round>直接上级/分管领导填写</ElTag>
</div>
<div class="feedback-table">
<div class="feedback-header">
<div class="feedback-cell header-cell">优势/劣势项</div>
<div class="feedback-cell header-cell">优势/劣势描述</div>
<div class="feedback-cell header-cell">行为事例</div>
<div class="feedback-cell header-cell">改进提升建议</div>
</div>
<div class="feedback-body">
<div class="feedback-left">
<div class="feedback-row-content">
<div class="feedback-cell label-cell">
<span class="feedback-tag success">优势</span>
</div>
<div class="feedback-cell">
<ElInput v-model="feedbackForm.strengthDesc" type="textarea" :rows="2" placeholder="请输入优势描述" />
</div>
<div class="feedback-cell">
<ElInput
v-model="feedbackForm.strengthExample"
type="textarea"
:rows="2"
placeholder="请输入行为事例"
/>
</div>
</div>
<div class="feedback-row-content">
<div class="feedback-cell label-cell">
<span class="feedback-tag warning">劣势</span>
</div>
<div class="feedback-cell">
<ElInput v-model="feedbackForm.weaknessDesc" type="textarea" :rows="2" placeholder="请输入劣势描述" />
</div>
<div class="feedback-cell">
<ElInput
v-model="feedbackForm.weaknessExample"
type="textarea"
:rows="2"
placeholder="请输入行为事例"
/>
</div>
</div>
</div>
<div class="feedback-right">
<ElInput
v-model="feedbackForm.improvementSuggestion"
type="textarea"
:rows="5"
placeholder="请输入改进提升建议"
/>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<span>当期绩效考核结果</span>
</div>
<div class="performance-result">
<div class="performance-label">
<span class="required-mark">*</span>
绩效考核结果
</div>
<div class="performance-input-wrapper">
<ElInput
v-model="performanceForm.score"
class="performance-input"
placeholder="请输入考核分数"
@input="handlePerformanceScoreInput"
@blur="handlePerformanceScoreBlur"
/>
<span v-if="performanceError" class="field-error">{{ performanceError }}</span>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<div class="section-title-left">
<span>下周期重点工作计划</span>
<span class="source-chip">{{ nextPlans.length }} 项计划</span>
</div>
</div>
<div class="plan-list layout-row">
<div v-if="!nextPlans.length">{{ EMPTY_TEXT }}</div>
<div v-for="(item, index) in nextPlans" :key="index" class="plan-card">
<div class="plan-index-cell">
<span class="row-index">{{ index + 1 }}</span>
</div>
<div class="plan-name-cell">
<strong>{{ item.workItem }}</strong>
</div>
<div class="plan-editor-grid">
<div class="field">
<label>具体目标</label>
<div class="rich-editor readonly" v-html="item.targetHtml"></div>
</div>
<div class="field">
<label>对他人协助的需求</label>
<div class="rich-editor readonly">{{ item.supportNeed || '' }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="signature-section">
<div class="signature-row">
<span>被考核人签名</span>
<ElInput v-model="signatureForm.employeeSign" class="signature-input" placeholder="请输入签名" />
<span>日期</span>
<ElDatePicker
v-model="signatureForm.employeeDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="signature-date"
/>
</div>
<div class="signature-row">
<span>上级签名</span>
<ElInput v-model="signatureForm.supervisorSign" class="signature-input" placeholder="请输入签名" />
<span>日期</span>
<ElDatePicker
v-model="signatureForm.supervisorDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="signature-date"
/>
</div>
</div>
<div class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" class="btn-submit" @click="openAuditDialog">开始审批</ElButton>
</div>
<BusinessFormDialog
v-model="auditDialogVisible"
title="月报审批"
preset="sm"
confirm-text="确认提交"
@confirm="submitAudit"
>
<div class="audit-form">
<div class="audit-field">
<label>审批结论</label>
<div class="audit-conclusion">
<button
type="button"
class="conclusion-btn"
:class="{ active: auditForm.conclusion === '通过', pass: auditForm.conclusion === '通过' }"
@click="auditForm.conclusion = '通过'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 8.5L7 10.5L11 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
通过
</button>
<button
type="button"
class="conclusion-btn"
:class="{ active: auditForm.conclusion === '不通过', reject: auditForm.conclusion === '不通过' }"
@click="auditForm.conclusion = '不通过'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
不通过
</button>
</div>
</div>
<div class="audit-field">
<label>审批意见</label>
<ElInput v-model="auditForm.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
</div>
</div>
</BusinessFormDialog>
</div>
</template>
<style scoped>
.card {
border: 1px solid rgba(216, 224, 232, 0.88);
border-radius: 18px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12);
padding-top: 20px;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 900;
}
.card-subtitle {
margin-top: 5px;
color: #667085;
font-size: 12px;
}
.form-head {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
padding: 20px;
}
.report-title {
display: flex;
gap: 12px;
align-items: center;
}
.type-mark {
width: 48px;
height: 48px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: 16px;
background: #ccfbf1;
color: #0f766e;
font-weight: 900;
}
.section {
margin: 0 20px 18px;
border: 1px solid #d8e0e8;
border-radius: 16px;
overflow: hidden;
background: #fff;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 13px 16px;
background: #f8fbfc;
border-bottom: 1px solid #d8e0e8;
font-weight: 900;
}
.section-title-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.section-title-right {
display: flex;
align-items: center;
gap: 10px;
}
.source-chip {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 12px;
font-weight: 800;
}
.compose-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
padding: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.compose-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.compose-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.field {
display: grid;
gap: 6px;
}
.field label {
color: #667085;
font-size: 12px;
font-weight: 800;
}
.radio-group-full {
width: 100%;
display: flex;
gap: 12px;
}
.radio-group-full :deep(.el-radio) {
flex: 1;
min-width: 0;
justify-content: center;
margin-right: 0;
}
.radio-group-full :deep(.el-radio.is-checked) {
border-color: #0f766e;
background: #f0fdfa;
}
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
color: #0f766e;
}
.review-grid,
.plan-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
}
.review-grid.layout-row,
.plan-list.layout-row {
grid-template-columns: 1fr;
}
.review-card,
.plan-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid #e5edf1;
border-radius: 12px;
background: #fbfdfe;
}
.review-card {
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr);
gap: 0;
padding: 0;
overflow: hidden;
}
.plan-card {
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr);
gap: 0;
padding: 0;
overflow: hidden;
}
.review-index-cell,
.review-name-cell,
.review-editor-grid,
.plan-index-cell,
.plan-name-cell,
.plan-editor-grid {
padding: 14px;
}
.review-index-cell,
.plan-index-cell {
display: grid;
place-items: center;
}
.review-name-cell,
.plan-name-cell {
display: grid;
align-items: center;
border-left: 1px solid #e5edf1;
}
.review-name-cell .work-title-line,
.plan-name-cell {
justify-content: center;
text-align: center;
}
.review-name-cell .work-title-line {
flex-direction: column;
gap: 4px;
}
.review-name-cell strong,
.plan-name-cell strong {
width: 100%;
min-width: 0;
flex: 0 1 auto;
color: #606266;
font-size: 14px;
font-weight: 400;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.review-card-head,
.plan-card-head {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 28px;
gap: 10px;
align-items: start;
}
.row-index {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 999px;
background: #0f766e;
color: #fff;
font-size: 13px;
font-weight: 900;
}
.review-title-fields,
.review-title-readonly,
.plan-title-readonly {
display: grid;
gap: 10px;
align-items: center;
}
.review-title-readonly {
grid-template-columns: 1fr;
}
.work-title-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.review-title-readonly strong,
.plan-title-readonly strong {
color: #606266;
font-size: 14px;
font-weight: 400;
line-height: 1.3;
}
.work-title-line span,
.inline-metrics {
display: flex;
align-items: center;
gap: 6px;
color: #667085;
font-size: 12px;
font-weight: 800;
}
.review-name-cell .work-title-line span {
justify-content: center;
width: 100%;
}
.inline-metrics :deep(.el-input-number) {
width: 92px;
}
.review-editor-grid,
.plan-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 12px;
}
.review-editor-grid,
.plan-editor-grid {
grid-column: 3 / span 2;
grid-row: 1;
border-left: 1px solid #e5edf1;
}
.item-remove-btn {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fff;
color: #dc2626;
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.item-remove-btn:hover {
background: #fef2f2;
}
.rich-editor,
.structured-input,
.fixed-textarea :deep(.el-textarea__inner),
.styled-textarea :deep(.el-textarea__inner) {
scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent;
}
.rich-editor::-webkit-scrollbar,
.structured-input::-webkit-scrollbar,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
.rich-editor::-webkit-scrollbar-track,
.structured-input::-webkit-scrollbar-track,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-track),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar-track) {
background: transparent;
}
.rich-editor::-webkit-scrollbar-thumb,
.structured-input::-webkit-scrollbar-thumb,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-thumb),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar-thumb) {
border-radius: 999px;
background: #94a3b8;
}
.rich-editor::-webkit-scrollbar-button,
.structured-input::-webkit-scrollbar-button,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-button),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar-button) {
width: 0;
height: 0;
display: none;
}
.rich-editor {
width: 100%;
min-height: 86px;
padding: 5px 11px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
box-sizing: border-box;
color: #334155;
font-size: 13px;
line-height: 1.6;
outline: none;
overflow: auto;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.rich-editor.readonly {
background: #fff;
cursor: default;
}
.rich-editor :deep(.rich-section) {
display: grid;
gap: 4px;
min-width: 0;
}
.rich-editor :deep(.rich-section + .rich-section) {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #cbd5e1;
}
.rich-editor :deep(.rich-section-title) {
position: relative;
min-width: 0;
padding-left: 14px;
color: #0f766e;
font-size: 13px;
font-weight: 800;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-section-title::before) {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
}
.rich-editor :deep(.rich-section-tasks) {
display: block;
padding-left: 14px;
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-section-task) {
display: inline;
margin-right: 5px;
white-space: normal;
}
.rich-editor :deep(.rich-section-task-title) {
color: #334155;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-inline-metrics) {
display: inline-flex;
align-items: center;
height: 18px;
margin-left: 3px;
padding: 0 6px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 11px;
font-weight: 800;
vertical-align: 1px;
user-select: none;
cursor: default;
}
.structured-input {
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
padding: 5px 11px;
cursor: pointer;
transition: border-color 0.2s;
height: 86px;
overflow: auto;
box-sizing: border-box;
}
.structured-input:hover {
border-color: #c0c4cc;
}
.structured-input.disabled {
background: #f8fafc;
color: #334155;
cursor: not-allowed;
}
.fixed-textarea :deep(.el-textarea__inner) {
height: 86px;
min-height: 86px;
max-height: 86px;
resize: none;
overflow: auto;
padding: 5px 11px;
line-height: 1.6;
white-space: pre-wrap;
}
.styled-textarea :deep(.el-textarea__inner) {
overflow: auto;
}
.review-editor-grid .field,
.plan-editor-grid .field {
display: flex;
flex-direction: column;
min-height: 0;
}
.review-editor-grid .rich-editor,
.plan-editor-grid .rich-editor,
.review-editor-grid .structured-input,
.plan-editor-grid .structured-input {
height: 100%;
min-height: 86px;
}
.structured-input.auto-height {
height: auto;
min-height: 86px;
max-height: none;
overflow: visible;
}
.structured-input-inner {
color: #334155;
line-height: 1.6;
font-size: 13px;
}
.structured-input-inner.plain {
white-space: pre-wrap;
}
.structured-section {
display: grid;
gap: 4px;
}
.structured-section + .structured-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #cbd5e1;
}
.structured-section-title {
position: relative;
padding-left: 14px;
color: #0f766e;
font-size: 13px;
font-weight: 800;
}
.structured-section-title::before {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
}
.structured-section-tasks {
display: block;
color: #334155;
padding-left: 14px;
font-size: 13px;
line-height: 1.6;
}
.structured-section-task {
display: inline;
margin-right: 5px;
white-space: normal;
}
.structured-section-task-title {
color: #334155;
overflow-wrap: anywhere;
}
.structured-inline-metrics {
display: inline-flex;
align-items: center;
gap: 3px;
height: 18px;
margin-left: 3px;
padding: 0 6px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 11px;
font-weight: 800;
vertical-align: 1px;
user-select: none;
cursor: default;
}
.structured-inline-metrics span + span::before {
content: '•';
margin-right: 3px;
}
.audit-form {
display: grid;
gap: 18px;
}
.audit-field {
display: grid;
gap: 8px;
}
.audit-field label {
color: #475467;
font-size: 13px;
font-weight: 800;
}
.audit-conclusion {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.conclusion-btn {
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
color: #475467;
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: all 0.18s ease;
}
.conclusion-btn:hover {
border-color: #0f766e;
color: #0f766e;
}
.conclusion-btn.active.pass {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
}
.conclusion-btn.active.reject {
border-color: #dc2626;
background: #fef2f2;
color: #dc2626;
}
/** 必填字段红色星号 */
.required-mark {
color: #dc2626;
margin-right: 2px;
}
/** 字段错误提示容器 */
.field-with-error {
display: flex;
flex-direction: column;
gap: 4px;
}
/** 字段错误提示文字 */
.field-error {
color: #dc2626;
font-size: 12px;
}
.feedback-table {
display: grid;
border: 1px solid #e2e8f0;
margin: 16px 16px 16px 16px;
border-radius: 14px;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.feedback-header {
display: grid;
grid-template-columns: 120px 1fr 1fr 280px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.feedback-body {
display: grid;
grid-template-columns: 1fr 350px;
}
.feedback-left {
display: grid;
}
.feedback-row-content {
display: grid;
grid-template-columns: 120px 1fr 1fr;
border-bottom: 1px solid #f1f5f9;
transition: background 0.15s ease;
}
.feedback-row-content:last-child {
border-bottom: none;
}
.feedback-row-content:hover {
background: #fafbfc;
}
.feedback-right {
padding: 16px;
border-left: 1px solid #f1f5f9;
background: #fafbfc;
display: flex;
align-items: stretch;
}
.feedback-right :deep(.el-textarea__inner) {
background: #fff;
height: 100% !important;
}
.feedback-cell {
padding: 16px;
border-right: 1px solid #f1f5f9;
display: flex;
align-items: flex-start;
}
.feedback-cell:last-child {
border-right: none;
}
.header-cell {
color: #475569;
font-size: 13px;
font-weight: 700;
justify-content: center;
letter-spacing: 0.3px;
}
.label-cell {
justify-content: center;
align-items: center;
background: #fafbfc;
}
.feedback-tag {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.5px;
}
.feedback-tag.success {
background: #ecfdf5;
color: #059669;
border: 1px solid #d1fae5;
}
.feedback-tag.warning {
background: #fff7ed;
color: #c2410c;
border: 1px solid #fed7aa;
}
.signature-section {
margin: 24px 20px;
padding: 16px 24px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.signature-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 10px 0;
color: #475569;
font-size: 14px;
font-weight: 600;
}
.signature-row + .signature-row {
border-top: 1px solid #f1f5f9;
}
.signature-input {
width: 140px;
}
.signature-input :deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
border-bottom: 1px solid #e2e8f0;
border-radius: 0;
padding: 0 4px;
}
.signature-input :deep(.el-input__inner) {
text-align: center;
color: #334155;
font-weight: 600;
}
.signature-input :deep(.el-input__inner::placeholder) {
color: #cbd5e1;
font-weight: 400;
}
.signature-date {
width: 130px;
}
.signature-date :deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
border-bottom: 1px solid #e2e8f0;
border-radius: 0;
padding: 0 4px;
}
.signature-date :deep(.el-input__inner) {
text-align: center;
color: #334155;
font-weight: 600;
}
.signature-date :deep(.el-input__inner::placeholder) {
color: #cbd5e1;
font-weight: 400;
}
.performance-result {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
margin: 16px 16px 16px 16px;
}
.performance-label {
color: #475569;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.performance-input {
width: 200px;
}
.performance-input :deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
border-bottom: 1px solid #e2e8f0;
border-radius: 0;
padding: 0 4px;
}
.performance-input :deep(.el-input__inner) {
color: #334155;
font-weight: 600;
text-align: center;
}
.performance-input :deep(.el-input__inner::placeholder) {
color: #cbd5e1;
font-weight: 400;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid #d8e0e8;
background: #fff;
}
.approval-form-actions {
position: sticky;
z-index: 5;
bottom: 0;
margin-top: auto;
margin-bottom: 0;
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
background: #f5f7fa;
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.btn-submit {
background: #0f766e !important;
border-color: #0f766e !important;
}
.btn-submit:hover {
background: #0d9488 !important;
border-color: #0d9488 !important;
}
@media (max-width: 1180px) {
.form-head,
.compose-grid,
.review-title-fields,
.review-title-readonly,
.plan-title-readonly,
.review-editor-grid,
.plan-editor-grid {
grid-template-columns: 1fr;
}
.review-card,
.plan-card {
grid-template-columns: 1fr;
}
.review-index-cell,
.review-name-cell,
.review-editor-grid,
.plan-index-cell,
.plan-name-cell,
.plan-editor-grid {
grid-column: auto;
grid-row: auto;
border-left: 0;
}
.review-name-cell,
.review-editor-grid,
.plan-name-cell,
.plan-editor-grid {
border-top: 1px solid #e5edf1;
}
.section-title {
align-items: flex-start;
}
}
</style>