1077 lines
28 KiB
Vue
1077 lines
28 KiB
Vue
<script setup lang="ts">
|
||
/* eslint-disable vue/no-mutating-props, no-plusplus, unicorn/prefer-dom-node-text-content, @typescript-eslint/no-dynamic-delete */
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||
import { useDict } from '@/hooks/business/dict';
|
||
import DictSelect from '@/components/custom/dict-select.vue';
|
||
import { formatPeriodLabel } from '../../shared/types';
|
||
|
||
const props = defineProps<{
|
||
reportType: string;
|
||
period: string;
|
||
mode?: 'add' | 'edit' | 'detail';
|
||
scene?: 'fill' | 'detail' | 'approval';
|
||
baseInfo?: Api.WorkReport.Project.ProjectReport | null;
|
||
model: Api.WorkReport.Project.ProjectReportSaveParams;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
back: [];
|
||
save: [];
|
||
submit: [];
|
||
requestApprove: [];
|
||
requestReject: [];
|
||
pullDefaultDraft: [];
|
||
}>();
|
||
|
||
interface WorkItem {
|
||
id: string;
|
||
title: string;
|
||
days?: number;
|
||
hours?: number;
|
||
priority: string;
|
||
progress: number;
|
||
removable?: boolean;
|
||
source?: Api.WorkReport.Project.ProjectReportItem;
|
||
sourceIndex?: number;
|
||
}
|
||
|
||
const activeEditField = ref('');
|
||
const inlinePlanVisible = ref(false);
|
||
const titleEditSnapshot = ref<Record<string, string>>({});
|
||
const EMPTY_TEXT = '本周期内暂无数据';
|
||
const isReadonly = computed(() => props.mode === 'detail' || props.scene === 'approval');
|
||
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
|
||
|
||
const projectForm = reactive({
|
||
get projectName() {
|
||
return props.baseInfo?.projectName || '--';
|
||
},
|
||
get projectOwner() {
|
||
return props.baseInfo?.projectOwnerName || '--';
|
||
},
|
||
get technicalOwner() {
|
||
return props.baseInfo?.technicalOwnerName || '--';
|
||
},
|
||
get members() {
|
||
return (props.baseInfo?.projectMemberSnapshot || []).map(item => item.userName);
|
||
},
|
||
get projectStatus() {
|
||
return props.model.projectStatusDesc || '';
|
||
},
|
||
set projectStatus(value: string) {
|
||
props.model.projectStatusDesc = value;
|
||
},
|
||
get progressPlan() {
|
||
return props.model.projectProgressPlan || '';
|
||
},
|
||
set progressPlan(value: string) {
|
||
props.model.projectProgressPlan = value;
|
||
},
|
||
get keyPoints() {
|
||
return props.model.projectKeyPoints || '';
|
||
},
|
||
set keyPoints(value: string) {
|
||
props.model.projectKeyPoints = value;
|
||
},
|
||
get problems() {
|
||
return props.model.projectProblems || '';
|
||
},
|
||
set problems(value: string) {
|
||
props.model.projectProblems = value;
|
||
}
|
||
});
|
||
|
||
const planForm = ref({
|
||
title: '',
|
||
priority: '3' as WorkItem['priority'],
|
||
progress: 0
|
||
});
|
||
|
||
function normalizePriorityCode(value?: string | number | null) {
|
||
const text = String(value ?? '').trim();
|
||
if (!text) return '';
|
||
|
||
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 toWorkItem(item: Api.WorkReport.Project.ProjectReportItem, index: number, removable: boolean): WorkItem {
|
||
return {
|
||
id: item.id || `${removable ? 'next' : 'current'}-${index}`,
|
||
title: item.itemTitle || '未命名工作',
|
||
hours: item.workHours || 0,
|
||
priority: normalizePriorityCode(item.priorityCode) || '3',
|
||
progress: item.progressRate || 0,
|
||
removable,
|
||
source: item,
|
||
sourceIndex: index
|
||
};
|
||
}
|
||
|
||
const currentWorks = computed<WorkItem[]>(() => {
|
||
return (props.model.currentItems || []).map((item, index) => toWorkItem(item, index, true));
|
||
});
|
||
|
||
const nextPlans = computed<WorkItem[]>(() => {
|
||
return (props.model.nextItems || []).map((item, index) => toWorkItem(item, index, true));
|
||
});
|
||
|
||
const MAX_VISIBLE_MEMBERS = 5;
|
||
/** 最多显示的成员数:受容器宽度动态限制 */
|
||
const maxVisibleMemberCount = ref(MAX_VISIBLE_MEMBERS);
|
||
const memberChipsEl = ref<HTMLElement | null>(null);
|
||
let memberResizeObserver: ResizeObserver | null = null;
|
||
|
||
function measureChipWidth(text: string): number {
|
||
// 估算 chip 宽度:8px 左右内边距 + 中文每字 12px、英文每字 7px
|
||
let width = 16;
|
||
for (const ch of text) {
|
||
width += /[\u4E00-\u9FA5]/.test(ch) ? 12 : 7;
|
||
}
|
||
return Math.ceil(width);
|
||
}
|
||
|
||
/** 根据容器宽度动态计算可容纳的成员数,上限为 MAX_VISIBLE_MEMBERS */
|
||
function measureVisibleCount() {
|
||
const el = memberChipsEl.value;
|
||
if (!el) return;
|
||
|
||
const containerWidth = el.clientWidth - 16; // 减去 padding
|
||
const members = projectForm.members;
|
||
if (!members.length) {
|
||
maxVisibleMemberCount.value = 0;
|
||
return;
|
||
}
|
||
|
||
// 先快速按数量上限分配,再校验总宽度
|
||
const moreChipWidth = 28; // +N 的预估宽度
|
||
const gap = 6;
|
||
|
||
let count = Math.min(members.length, MAX_VISIBLE_MEMBERS);
|
||
for (; count > 0; count--) {
|
||
const showMore = count < members.length;
|
||
const reserved = showMore ? moreChipWidth + gap : 0;
|
||
let total = reserved;
|
||
for (let i = 0; i < count; i++) {
|
||
total += measureChipWidth(members[i]) + gap;
|
||
}
|
||
if (total <= containerWidth) break;
|
||
}
|
||
|
||
maxVisibleMemberCount.value = count;
|
||
}
|
||
|
||
const visibleMembers = computed(() => projectForm.members.slice(0, maxVisibleMemberCount.value));
|
||
const hiddenMembers = computed(() => projectForm.members.slice(maxVisibleMemberCount.value));
|
||
|
||
onMounted(() => {
|
||
if (!memberResizeObserver && typeof ResizeObserver !== 'undefined') {
|
||
memberResizeObserver = new ResizeObserver(() => measureVisibleCount());
|
||
}
|
||
if (memberChipsEl.value) {
|
||
memberResizeObserver?.observe(memberChipsEl.value);
|
||
nextTick(measureVisibleCount);
|
||
}
|
||
});
|
||
|
||
watch(memberChipsEl, el => {
|
||
memberResizeObserver?.disconnect();
|
||
if (el) {
|
||
memberResizeObserver?.observe(el);
|
||
nextTick(measureVisibleCount);
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => projectForm.members.length,
|
||
() => {
|
||
nextTick(measureVisibleCount);
|
||
}
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
memberResizeObserver?.disconnect();
|
||
memberResizeObserver = null;
|
||
});
|
||
const totalHours = computed(() => {
|
||
const baseTotalWorkHours = Number(props.baseInfo?.totalWorkHours ?? 0);
|
||
if (Number.isFinite(baseTotalWorkHours) && baseTotalWorkHours > 0) return baseTotalWorkHours;
|
||
|
||
return (props.model.currentItems || []).reduce((sum, item) => sum + Number(item.workHours || 0), 0);
|
||
});
|
||
const isDuplicatePlanTitle = computed(() => {
|
||
const title = planForm.value.title.trim();
|
||
return Boolean(title) && (props.model.nextItems || []).some(item => item.itemTitle.trim() === title);
|
||
});
|
||
|
||
function focusEditField(key: string) {
|
||
activeEditField.value = key;
|
||
}
|
||
|
||
function blurEditField(key: string) {
|
||
if (activeEditField.value === key) activeEditField.value = '';
|
||
}
|
||
|
||
function syncRichProjectField(field: 'projectStatus' | 'progressPlan' | 'keyPoints' | 'problems', event: Event) {
|
||
const target = event.currentTarget as HTMLElement;
|
||
projectForm[field] = target.innerText.trim();
|
||
blurEditField(field);
|
||
}
|
||
|
||
function resetPlanForm() {
|
||
planForm.value = { title: '', priority: '3', progress: 0 };
|
||
}
|
||
|
||
function showInlinePlanForm() {
|
||
inlinePlanVisible.value = true;
|
||
resetPlanForm();
|
||
}
|
||
|
||
function cancelInlinePlan() {
|
||
inlinePlanVisible.value = false;
|
||
resetPlanForm();
|
||
}
|
||
|
||
function submitInlinePlan() {
|
||
const title = planForm.value.title.trim();
|
||
if (!title) return;
|
||
if (isDuplicatePlanTitle.value) {
|
||
ElMessage.warning('计划名称重复,请调整后再新增');
|
||
return;
|
||
}
|
||
props.model.nextItems.push({
|
||
itemTitle: planForm.value.title.trim(),
|
||
workHours: 0,
|
||
priorityCode: normalizePriorityCode(planForm.value.priority),
|
||
progressRate: planForm.value.progress,
|
||
_isNew: true
|
||
} as Api.WorkReport.Project.ProjectReportItem & { _isNew: boolean });
|
||
resetPlanForm();
|
||
inlinePlanVisible.value = false;
|
||
}
|
||
|
||
function removePlanItem(index: number) {
|
||
const item = nextPlans.value[index];
|
||
if (item?.sourceIndex !== undefined) props.model.nextItems.splice(item.sourceIndex, 1);
|
||
}
|
||
|
||
function removeCurrentWorkItem(index: number) {
|
||
const item = currentWorks.value[index];
|
||
if (item?.sourceIndex !== undefined) props.model.currentItems.splice(item.sourceIndex, 1);
|
||
}
|
||
|
||
function startTitleEdit(item: WorkItem) {
|
||
titleEditSnapshot.value[item.id] = item.title;
|
||
}
|
||
|
||
function notifyTitleSaved(item: WorkItem) {
|
||
const previousTitle = titleEditSnapshot.value[item.id] ?? item.title;
|
||
const nextTitle = item.title.trim();
|
||
if (!nextTitle) {
|
||
item.title = previousTitle;
|
||
delete titleEditSnapshot.value[item.id];
|
||
return;
|
||
}
|
||
item.title = nextTitle;
|
||
if (item.source) item.source.itemTitle = nextTitle;
|
||
delete titleEditSnapshot.value[item.id];
|
||
// if (nextTitle !== previousTitle) {
|
||
// ElMessage.success('名称已成功修改');
|
||
// }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="card form-page">
|
||
<div class="section">
|
||
<div class="section-title">
|
||
<span>基础信息</span>
|
||
<div v-if="mode === 'edit' && !isReadonly" class="section-title-right">
|
||
<ElButton size="small" plain type="primary" @click="emit('pullDefaultDraft')">
|
||
<template #icon>
|
||
<icon-mdi-refresh class="text-icon" />
|
||
</template>
|
||
刷新
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
<div class="compose-grid">
|
||
<div class="field">
|
||
<label>项目名称</label>
|
||
<ElInput v-model="projectForm.projectName" disabled />
|
||
</div>
|
||
<div class="field">
|
||
<label>项目负责人</label>
|
||
<ElInput v-model="projectForm.projectOwner" disabled />
|
||
</div>
|
||
<div class="field">
|
||
<label>技术负责人</label>
|
||
<ElInput v-model="projectForm.technicalOwner" disabled />
|
||
</div>
|
||
<div class="field members-field">
|
||
<label>项目组成员</label>
|
||
<div ref="memberChipsEl" class="member-chips">
|
||
<span v-for="member in visibleMembers" :key="member" class="member-chip">{{ member }}</span>
|
||
<ElTooltip v-if="hiddenMembers.length" placement="top" :content="hiddenMembers.join('、')">
|
||
<span class="member-chip more">+{{ hiddenMembers.length }}</span>
|
||
</ElTooltip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="basic-status-editor">
|
||
<label>项目状况</label>
|
||
<div
|
||
class="rich-editor"
|
||
:contenteditable="!isReadonly"
|
||
spellcheck="false"
|
||
:data-placeholder="isReadonly ? undefined : '请输入项目状况'"
|
||
@focus="focusEditField('projectStatus')"
|
||
@blur="syncRichProjectField('projectStatus', $event)"
|
||
>
|
||
{{ projectForm.projectStatus }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">整体计划进度</div>
|
||
<div class="single-editor">
|
||
<div
|
||
class="rich-editor"
|
||
:contenteditable="!isReadonly"
|
||
spellcheck="false"
|
||
:data-placeholder="isReadonly ? undefined : '请输入整体计划进度'"
|
||
@focus="focusEditField('progressPlan')"
|
||
@blur="syncRichProjectField('progressPlan', $event)"
|
||
>
|
||
{{ projectForm.progressPlan }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">
|
||
<div class="section-title-left">
|
||
<span>本期工作内容</span>
|
||
<span class="source-chip">{{ currentWorks.length }} 项工作</span>
|
||
<span class="source-chip">共 {{ totalHours }}h</span>
|
||
</div>
|
||
</div>
|
||
<div class="review-grid layout-row">
|
||
<div v-if="!currentWorks.length">{{ EMPTY_TEXT }}</div>
|
||
<div v-for="(item, index) in currentWorks" :key="item.id" class="review-card compact-work-card">
|
||
<div class="review-card-head" :class="{ 'no-remove': item.removable === false }">
|
||
<span class="row-index">{{ index + 1 }}</span>
|
||
<div class="review-title-readonly">
|
||
<div class="work-title-line">
|
||
<input
|
||
v-model="item.title"
|
||
class="work-title-input"
|
||
aria-label="本期工作内容名称"
|
||
:disabled="isReadonly"
|
||
@focus="startTitleEdit(item)"
|
||
@blur="notifyTitleSaved(item)"
|
||
/>
|
||
<span class="meta-chip priority-chip">{{ resolvePriorityLabel(item.priority) }}</span>
|
||
<span class="meta-chip">进度 {{ item.progress }}%</span>
|
||
<span class="meta-chip">共 {{ item.hours }}h</span>
|
||
</div>
|
||
</div>
|
||
<ElPopconfirm
|
||
v-if="item.removable !== false && !isReadonly"
|
||
title="确认删除这条工作内容吗?"
|
||
confirm-button-text="删除"
|
||
cancel-button-text="取消"
|
||
width="220"
|
||
@confirm="removeCurrentWorkItem(index)"
|
||
>
|
||
<template #reference>
|
||
<button class="item-remove-btn" aria-label="删除工作内容">×</button>
|
||
</template>
|
||
</ElPopconfirm>
|
||
</div>
|
||
</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 class="section-title-right">
|
||
<ElButton v-if="!isReadonly" size="small" :disabled="inlinePlanVisible" @click="showInlinePlanForm">
|
||
新增计划
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
<div class="plan-list layout-row">
|
||
<div v-if="!nextPlans.length && !inlinePlanVisible">{{ EMPTY_TEXT }}</div>
|
||
<div v-for="(item, index) in nextPlans" :key="item.id" class="plan-card compact-work-card">
|
||
<div class="plan-card-head" :class="{ 'no-remove': item.removable === false }">
|
||
<span class="row-index">{{ index + 1 }}</span>
|
||
<div class="plan-title-readonly">
|
||
<div class="work-title-line">
|
||
<input
|
||
v-model="item.title"
|
||
class="work-title-input"
|
||
aria-label="下期计划工作内容名称"
|
||
:disabled="isReadonly"
|
||
@focus="startTitleEdit(item)"
|
||
@blur="notifyTitleSaved(item)"
|
||
/>
|
||
<span class="meta-chip priority-chip">{{ resolvePriorityLabel(item.priority) }}</span>
|
||
<span class="meta-chip">进度 {{ item.progress }}%</span>
|
||
</div>
|
||
</div>
|
||
<ElPopconfirm
|
||
v-if="item.removable !== false && !isReadonly"
|
||
title="确认删除这条计划吗?"
|
||
confirm-button-text="删除"
|
||
cancel-button-text="取消"
|
||
width="220"
|
||
@confirm="removePlanItem(index)"
|
||
>
|
||
<template #reference>
|
||
<button class="item-remove-btn" aria-label="删除计划">×</button>
|
||
</template>
|
||
</ElPopconfirm>
|
||
</div>
|
||
</div>
|
||
<div v-if="inlinePlanVisible && !isReadonly" class="plan-card compact-work-card inline-plan-card">
|
||
<div class="plan-card-head">
|
||
<span class="row-index">{{ nextPlans.length + 1 }}</span>
|
||
<div class="plan-title-readonly">
|
||
<div class="work-title-line inline-edit-line">
|
||
<div class="inline-title-field">
|
||
<ElInput v-model="planForm.title" class="inline-title-input" placeholder="请输入计划名称" />
|
||
<span v-if="isDuplicatePlanTitle" class="duplicate-tip">计划名称重复,请调整后再新增</span>
|
||
</div>
|
||
<DictSelect
|
||
v-model="planForm.priority"
|
||
class="inline-select"
|
||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||
placeholder="优先级"
|
||
:clearable="false"
|
||
/>
|
||
<ElInputNumber
|
||
v-model="planForm.progress"
|
||
class="inline-progress"
|
||
:min="0"
|
||
:max="100"
|
||
:step="5"
|
||
:precision="1"
|
||
controls-position="right"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<button class="item-remove-btn" aria-label="取消新增计划" @click="cancelInlinePlan">×</button>
|
||
</div>
|
||
<div class="inline-plan-actions">
|
||
<ElButton size="small" @click="cancelInlinePlan">取消</ElButton>
|
||
<ElButton
|
||
size="small"
|
||
type="primary"
|
||
:disabled="!planForm.title.trim() || isDuplicatePlanTitle"
|
||
@click="submitInlinePlan"
|
||
>
|
||
确认新增
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">要点描述</div>
|
||
<div class="single-editor">
|
||
<div
|
||
class="rich-editor"
|
||
:contenteditable="!isReadonly"
|
||
spellcheck="false"
|
||
:data-placeholder="isReadonly ? undefined : '请输入要点描述'"
|
||
@focus="focusEditField('keyPoints')"
|
||
@blur="syncRichProjectField('keyPoints', $event)"
|
||
>
|
||
{{ projectForm.keyPoints }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">项目问题</div>
|
||
<div class="single-editor">
|
||
<div
|
||
class="rich-editor"
|
||
:contenteditable="!isReadonly"
|
||
spellcheck="false"
|
||
:data-placeholder="isReadonly ? undefined : '请输入项目问题'"
|
||
@focus="focusEditField('problems')"
|
||
@blur="syncRichProjectField('problems', $event)"
|
||
>
|
||
{{ projectForm.problems }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!isReadonly" class="form-actions">
|
||
<!-- <ElButton>重置表单</ElButton>-->
|
||
<ElButton @click="emit('save')">保存草稿</ElButton>
|
||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||
</div>
|
||
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
||
<ElButton @click="emit('back')">退出</ElButton>
|
||
<ElButton type="primary" @click="emit('requestApprove')">审批</ElButton>
|
||
</div>
|
||
</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 {
|
||
padding: 20px 64px 20px 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: var(--el-color-primary-light-8);
|
||
color: var(--el-color-primary);
|
||
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,
|
||
.section-title-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.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: 1.25fr 0.75fr 0.75fr 1.25fr;
|
||
gap: 14px;
|
||
padding: 16px;
|
||
align-items: start;
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.field label {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.basic-status-editor {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 0 16px 16px;
|
||
}
|
||
|
||
.basic-status-editor label {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.member-chips {
|
||
height: 32px;
|
||
min-height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: nowrap;
|
||
padding: 4px 8px;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
background: #f5f7fa;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.member-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 20px;
|
||
flex: 0 0 auto;
|
||
padding: 0 8px;
|
||
border-radius: 999px;
|
||
background: #fff;
|
||
color: #475467;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.member-chip.more {
|
||
background: var(--el-color-primary-light-9);
|
||
color: var(--el-color-primary);
|
||
cursor: default;
|
||
}
|
||
|
||
.single-editor {
|
||
padding: 16px;
|
||
}
|
||
|
||
.review-grid,
|
||
.plan-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.review-grid.layout-row,
|
||
.plan-list.layout-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.review-card,
|
||
.plan-card {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
border: 1px solid #e5edf1;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
}
|
||
|
||
.compact-work-card {
|
||
min-height: 58px;
|
||
align-content: center;
|
||
padding: 10px 12px;
|
||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
|
||
transition:
|
||
border-color 0.2s,
|
||
box-shadow 0.2s,
|
||
transform 0.2s;
|
||
}
|
||
|
||
.compact-work-card:hover {
|
||
border-color: var(--el-color-primary-light-5);
|
||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.review-card-head,
|
||
.plan-card-head {
|
||
display: grid;
|
||
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.review-card-head.no-remove,
|
||
.plan-card-head.no-remove {
|
||
grid-template-columns: 28px minmax(0, 1fr);
|
||
}
|
||
|
||
.row-index {
|
||
width: 30px;
|
||
height: 30px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 999px;
|
||
background: var(--el-color-primary);
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.compact-work-card .row-index {
|
||
width: 28px;
|
||
height: 28px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.review-title-readonly,
|
||
.plan-title-readonly {
|
||
display: grid;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.work-title-line {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: nowrap;
|
||
min-height: 30px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.review-title-readonly strong,
|
||
.plan-title-readonly strong {
|
||
color: #606266;
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.compact-work-card .review-title-readonly strong,
|
||
.compact-work-card .plan-title-readonly strong {
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
line-height: 1.3;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.work-title-input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
flex: 1 1 160px;
|
||
border: 0;
|
||
border-bottom: 1px solid transparent;
|
||
background: transparent;
|
||
color: #606266;
|
||
font: inherit;
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
line-height: 1.3;
|
||
outline: none;
|
||
overflow: hidden;
|
||
padding: 1px 0;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.work-title-input:focus {
|
||
border-bottom-color: var(--el-color-primary);
|
||
}
|
||
|
||
.work-title-line span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 24px;
|
||
padding: 0 8px;
|
||
border-radius: 999px;
|
||
background: #f3f7f9;
|
||
color: #667085;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.work-title-line .priority-chip {
|
||
background: #fff7ed;
|
||
color: #c2410c;
|
||
}
|
||
|
||
.meta-chip {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.fixed-textarea :deep(.el-textarea__inner) {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #94a3b8 transparent;
|
||
}
|
||
|
||
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar) {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-track) {
|
||
background: transparent;
|
||
}
|
||
|
||
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-thumb) {
|
||
border-radius: 999px;
|
||
background: #94a3b8;
|
||
}
|
||
|
||
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-button) {
|
||
width: 0;
|
||
height: 0;
|
||
display: none;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.rich-editor {
|
||
min-height: 86px;
|
||
padding: 5px 11px;
|
||
border-radius: 9px;
|
||
outline: none;
|
||
background: #fff;
|
||
box-shadow: 0 0 0 1px #d8e0e8 inset;
|
||
color: #334155;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
overflow: auto;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #94a3b8 transparent;
|
||
}
|
||
|
||
.rich-editor:hover {
|
||
box-shadow: 0 0 0 1px #b8c4cf inset;
|
||
}
|
||
|
||
.rich-editor:focus {
|
||
box-shadow:
|
||
0 0 0 1px var(--el-color-primary) inset,
|
||
0 0 0 2px var(--el-color-primary-light-8);
|
||
}
|
||
|
||
.rich-editor:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: #98a2b3;
|
||
}
|
||
|
||
.rich-editor::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
.rich-editor::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.rich-editor::-webkit-scrollbar-thumb {
|
||
border-radius: 999px;
|
||
background: #94a3b8;
|
||
}
|
||
|
||
.rich-editor::-webkit-scrollbar-button {
|
||
width: 0;
|
||
height: 0;
|
||
display: none;
|
||
}
|
||
|
||
.inline-plan-card {
|
||
border-color: var(--el-color-primary-light-5);
|
||
background: #f8fbfc;
|
||
}
|
||
|
||
.inline-edit-line {
|
||
overflow: visible;
|
||
}
|
||
|
||
.inline-title-field {
|
||
flex: 1 1 220px;
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.inline-title-input {
|
||
width: 100%;
|
||
}
|
||
|
||
.inline-select {
|
||
width: 96px;
|
||
flex: 0 0 96px;
|
||
}
|
||
|
||
.inline-progress {
|
||
width: 132px;
|
||
flex: 0 0 132px;
|
||
}
|
||
|
||
.duplicate-tip {
|
||
color: #dc2626;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.inline-plan-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
.inline-plan-card :deep(.el-input__wrapper),
|
||
.inline-plan-card :deep(.el-select__wrapper) {
|
||
border-radius: 9px;
|
||
box-shadow: 0 0 0 1px #d8e0e8 inset;
|
||
}
|
||
|
||
.inline-plan-card :deep(.el-input-number__increase) {
|
||
border-top-right-radius: 9px;
|
||
}
|
||
|
||
.inline-plan-card :deep(.el-input-number__decrease) {
|
||
border-bottom-right-radius: 9px;
|
||
}
|
||
|
||
.inline-progress :deep(.el-input__wrapper) {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.inline-plan-card :deep(.el-input__wrapper.is-focus),
|
||
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
|
||
box-shadow:
|
||
0 0 0 1px var(--el-color-primary) inset,
|
||
0 0 0 2px var(--el-color-primary-light-8);
|
||
}
|
||
|
||
.form-actions {
|
||
position: sticky;
|
||
z-index: 5;
|
||
bottom: 0;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
margin-top: auto;
|
||
margin-bottom: 0;
|
||
padding: 14px 20px;
|
||
border-top: 1px solid #d8e0e8;
|
||
border-bottom-left-radius: 18px;
|
||
border-bottom-right-radius: 18px;
|
||
background: #f5f7fa;
|
||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.compose-grid,
|
||
.review-grid,
|
||
.plan-list {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.section-title {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.inline-edit-line {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.inline-title-field,
|
||
.inline-select,
|
||
.inline-progress {
|
||
width: 100%;
|
||
flex-basis: auto;
|
||
}
|
||
}
|
||
</style>
|