优化项目管理

This commit is contained in:
2026-09-14 17:30:12 +08:00
parent a26c98b92b
commit 4ab5effce6
10 changed files with 434 additions and 158 deletions
@@ -454,7 +454,7 @@ watch(
}
);
/** 选择签订日期:生效日期为空时默认带入(生效日期仍可手动修改,改过则不再被覆盖) */
/** 签订日期兜底(编辑历史数据/程序赋值):生效日期为空时带入签订日期 */
watch(
() => form.sign_date,
(nv) => {
@@ -462,9 +462,13 @@ watch(
}
);
/** 签订日期变更(用户操作):自动带入生效日期 */
/**
* 签订日期变更(用户操作):生效日期始终同步为签订日期。
* 生效日期此后仍可单独调整;但签订日期再次变化时会重新同步,
* 保持「生效日期默认等于签订日期」的口径。
*/
function onSignDateChange(val) {
if (val && !form.effective_date) form.effective_date = val;
if (val) form.effective_date = val;
}
/** 合同形式文案:2双方 3三方 4四方 */
@@ -2,6 +2,7 @@
<div class="contract-payback">
<div class="section-head">
<span class="section-title">回款计划</span>
<!-- 回款已收齐(进度 100%)时不可再新建回款计划 -->
<el-tooltip :disabled="!paidOff" content="回款已全部完成(进度 100%),不可再新建回款计划" placement="top">
<span class="btn-wrap">
<el-button type="primary" size="small" :icon="Plus" :disabled="paidOff" @click="openCreate">
@@ -88,9 +89,18 @@
<span>已回款:<b class="success">¥{{ formatMoney(progressSummary.total_received) }}</b></span>
<span>待回款:<b class="warning">¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}</b></span>
</div>
<el-tooltip :disabled="list.length > 0" content="回款进度以回款计划为先导条件,请先新建回款计划" placement="top">
<!-- 无回款计划时不可登记(先导条件);回款已收齐(100%)时同样不可再新建 -->
<el-tooltip
:disabled="canCreateReceive"
:content="
list.length
? '回款已全部完成(进度 100%),不可再新建回款'
: '回款进度以回款计划为先导条件,请先新建回款计划'
"
placement="top"
>
<span class="btn-wrap">
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive()">
<el-button type="primary" size="small" :icon="Plus" :disabled="!canCreateReceive" @click="openReceive()">
新建回款
</el-button>
</span>
@@ -115,15 +125,15 @@
<el-table-column label="回款方式" width="100" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
</el-table-column>
<el-table-column label="流水回执" width="110" align="center">
<el-table-column label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
<el-table-column label="流水回执" width="110" align="center" fixed="right">
<template #default="{ row }">
<el-link v-if="row.receipt_url" type="primary" :href="row.receipt_url" target="_blank" :underline="false">查看回执</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button
@@ -135,23 +145,15 @@
>
补回执
</el-button>
<el-tooltip
:disabled="Number((planOf(row.payback_id) || {}).status) !== 2"
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
placement="top"
<!-- 所属回款计划已完成(回款已收齐)的记录不可删除,不显示按钮 -->
<el-button
v-if="Number((planOf(row.payback_id) || {}).status) !== 2"
link
type="danger"
size="small"
@click="handleDeleteRecord(row)"
>删除</el-button
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number((planOf(row.payback_id) || {}).status) === 2"
@click="handleDeleteRecord(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
<template #empty>
@@ -408,13 +410,16 @@ function paybackItemStatusTag(row) {
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(row)] || "info";
}
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后不可再新建回款计划 / 回款 */
const paidOff = computed(() => {
const planned = Number(progressSummary.value.total_planned) || 0;
if (planned <= 0) return false;
return (Number(progressSummary.value.total_received) || 0) >= planned;
});
/** 「新建回款」可用条件:已存在回款计划(先导条件)且尚未全部收齐(进度未到 100%) */
const canCreateReceive = computed(() => list.value.length > 0 && !paidOff.value);
/** 回款进度百分比 */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
@@ -423,7 +428,7 @@ function paybackPercent(row) {
}
function openCreate() {
// 兜底拦截:回款已收齐时不允许再建计划
// 兜底拦截:回款已全部收齐(进度 100%)时不允许再建计划
if (paidOff.value) {
ElMessage.warning("回款已全部完成(进度 100%),不可再新建回款计划");
return;
@@ -516,6 +521,11 @@ function openReceive() {
ElMessage.warning("请先新建回款计划,回款进度以计划为先导条件");
return;
}
// 兜底拦截:回款已全部收齐(进度 100%)时不允许再登记回款
if (paidOff.value) {
ElMessage.warning("回款已全部完成(进度 100%),不可再新建回款");
return;
}
const first = list.value[0];
Object.assign(receiveForm, {
payback_id: first.id,
@@ -653,7 +663,7 @@ async function submitReceive() {
}
}
/** 删除一条回款记录(会同步回写计划的已回款金额;所属计划已完成时禁止) */
/** 删除一条回款记录(后端同步重算计划的已回款金额与状态;所属计划已完成时禁止) */
async function handleDeleteRecord(row) {
if (Number((planOf(row.payback_id) || {}).status) === 2) {
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
+11 -3
View File
@@ -372,11 +372,19 @@ export function projectOverdue(row) {
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(dateStr).text : "" };
}
/** 合同逾期信息:{ overdue, days, text }(text 为"逾期 x 年 y 天"日历换算) */
/** 合同逾期信息:{ overdue, days, text }(text 为"逾期 x 年 y 天"日历换算)
* 两个口径取更早(更严重)者:
* - 合同结束日期(expire_date)已过;
* - 回款计划逾期(payback_overdue_date:未收齐且已过计划回款日期的最早日期,后端随列表/详情返回)。
* 已完成(2) / 已作废(3) 合同不提示。 */
export function contractOverdue(row) {
const days = overdueDays(row?.expire_date);
const expireDays = overdueDays(row?.expire_date);
const paybackDays = overdueDays(row?.payback_overdue_date);
const days = Math.max(expireDays || 0, paybackDays || 0);
const overdue = days > 0 && ![2, 3].includes(Number(row?.status));
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(row?.expire_date).text : "" };
// 文案按「更早(逾期更久)」的日期换算
const baseDate = paybackDays > expireDays ? row?.payback_overdue_date : row?.expire_date;
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(baseDate).text : "" };
}
/** 回款明细逾期信息:{ overdue, days, text }
@@ -15,25 +15,19 @@
</div>
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
<el-tooltip
:disabled="Number(detail.status) !== 2"
content="该回款计划已完成(回款已收齐),不允许删除"
placement="top"
>
<span>
<el-button
type="danger"
link
:icon="Delete"
:disabled="Number(detail.status) === 2"
@click="handleDelete"
style="margin-left: 0px"
>
删除
</el-button>
</span>
</el-tooltip>
<!-- 已完成的回款计划(回款已收齐)不可编辑、不可删除,不显示对应按钮 -->
<template v-if="Number(detail.status) !== 2">
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
<el-button
type="danger"
link
:icon="Delete"
@click="handleDelete"
style="margin-left: 0px"
>
删除
</el-button>
</template>
</div>
<el-descriptions :column="2" border class="desc-block">
@@ -98,7 +92,23 @@
<span class="section-tip">
共 {{ records.length }} 笔,累计 ¥{{ formatMoney(receivedSum) }}
</span>
<el-button type="primary" size="small" :icon="Plus" @click="openRecord()">新建回款记录</el-button>
<!-- 计划已完成(回款已收齐)时不可再新建回款记录 -->
<el-tooltip
:disabled="Number(detail.status) !== 2"
content="该回款计划已完成(回款已收齐),不可再新建回款记录"
placement="top"
>
<span>
<el-button
type="primary"
size="small"
:icon="Plus"
:disabled="Number(detail.status) === 2"
@click="openRecord()"
>新建回款记录</el-button
>
</span>
</el-tooltip>
</div>
</div>
@@ -133,23 +143,15 @@
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-tooltip
:disabled="Number(detail.status) !== 2"
content="该回款计划已完成(回款已收齐),不允许删除回款记录"
placement="top"
<!-- 所属回款计划已完成(回款已收齐)的记录不可删除,不显示按钮 -->
<el-button
v-if="Number(detail.status) !== 2"
link
type="danger"
size="small"
@click="handleDeleteRecord(row)"
>删除</el-button
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number(detail.status) === 2"
@click="handleDeleteRecord(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
<template #empty>
@@ -389,6 +391,11 @@ function todayStr() {
* 进度节点在弹窗内选择(按进度选择性付款),不指定时按整单登记。
*/
function openRecord() {
// 兜底拦截:计划已完成(回款已收齐)时不允许再登记
if (Number(detail.value.status) === 2) {
ElMessage.warning("该回款计划已完成(回款已收齐),不可再新建回款记录");
return;
}
Object.assign(recordForm, {
amount: 0,
received_date: todayStr(),
+15 -36
View File
@@ -135,24 +135,11 @@
<template #default="{ row }">
<template v-if="row.__isPlan">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-tooltip
:disabled="Number(row.status) !== 2"
content="该回款计划已完成(回款已收齐),不允许删除"
placement="top"
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number(row.status) === 2"
@click="handleRowDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
<!-- 已完成的回款计划(回款已收齐)不可编辑、不可删除,不显示对应按钮 -->
<template v-if="Number(row.status) !== 2">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleRowDelete(row)">删除</el-button>
</template>
</template>
<template v-else>
<el-link
@@ -174,23 +161,15 @@
>
补回执
</el-button>
<el-tooltip
:disabled="Number(row.__planStatus) !== 2"
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
placement="top"
<!-- 所属回款计划已完成(回款已收齐)的记录不可删除,不显示按钮 -->
<el-button
v-if="Number(row.__planStatus) !== 2"
link
type="danger"
size="small"
@click="handleDeleteRecord(row)"
>删除</el-button
>
<span>
<el-button
link
type="danger"
size="small"
:disabled="Number(row.__planStatus) === 2"
@click="handleDeleteRecord(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template>
</template>
</el-table-column>
@@ -299,7 +278,7 @@ async function fetchList() {
children: (p.children || []).map((r) => ({
...r,
__isPlan: false,
__planStatus: p.status, // 带上所属计划状态,用于禁止删除已完成计划的回款记录
__planStatus: p.status, // 所属计划状态:已完成时记录行不显示删除按钮
__rowKey: `rec_${r.id}`,
})),
}));
@@ -382,7 +361,7 @@ async function handleRowDelete(row) {
}
}
/** 删除子行的回款记录(同步回写计划已回款金额;所属计划已完成时禁止) */
/** 删除子行的回款记录(后端同步重算计划已回款金额与状态;所属计划已完成时禁止) */
async function handleDeleteRecord(row) {
if (Number(row.__planStatus) === 2) {
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
@@ -85,50 +85,84 @@
</div>
</div>
<!-- 列表视图 -->
<!-- 列表视图:子文件夹(在前)与同级文件混排展示 -->
<el-table
v-if="viewMode === 'list'"
:data="list"
:data="listRows"
v-loading="loading"
stripe
border
size="small"
>
<el-table-column label="文件名" min-width="150" show-overflow-tooltip>
<el-table-column label="名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.file_url" target="_blank" class="file-link">
<!-- 子文件夹:点击进入(与左侧树点击一致) -->
<span v-if="row.__isFolder" class="folder-link" @click="enterFolder(row)">
<el-icon class="folder-link-icon"><Folder /></el-icon>
<span class="folder-link-name">{{ row.name }}</span>
<span v-if="row.doc_count > 0" class="tree-node-count">{{ row.doc_count }}</span>
</span>
<a v-else :href="row.file_url" target="_blank" class="file-link">
{{ row.file_name || row.title || "-" }}
</a>
</template>
</el-table-column>
<el-table-column label="类型" width="64" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ docTypeText(row.doc_type) }}</el-tag>
<el-tag v-if="row.__isFolder" size="small" type="warning" effect="plain">文件夹</el-tag>
<el-tag v-else size="small" effect="plain">{{ docTypeText(row.doc_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="所在文件夹" min-width="88" show-overflow-tooltip>
<template #default="{ row }">{{ row.folder_name || projectName || "-" }}</template>
<template #default="{ row }">
{{ row.__isFolder ? currentFolderLabel : row.folder_name || projectName || "-" }}
</template>
</el-table-column>
<el-table-column label="大小" min-width="72" align="right">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
<template #default="{ row }">{{ row.__isFolder ? "-" : formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="creator_name" label="上传人" min-width="70" show-overflow-tooltip>
<template #default="{ row }">{{ row.__isFolder ? "-" : row.creator_name || "-" }}</template>
</el-table-column>
<el-table-column prop="creator_name" label="上传人" min-width="70" show-overflow-tooltip />
<el-table-column label="上传时间" min-width="132">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
<template #default="{ row }">{{ row.__isFolder ? "-" : formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="64" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-button
v-if="row.__isFolder"
link
type="primary"
size="small"
@click="enterFolder(row)"
>打开</el-button
>
<el-button v-else link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无项目文档" :image-size="60" /></template>
<template #empty>
<el-empty description="该文件夹暂无内容" :image-size="60" />
</template>
</el-table>
<!-- 图标视图:文件卡片(点击文件图标/名称打开,删除在卡片底部) -->
<!-- 图标视图:子文件夹卡片 + 文件卡片混排(文件夹点击进入;文件点击打开,删除在卡片底部) -->
<div v-else class="doc-grid" v-loading="loading">
<div
v-for="f in subFolders"
:key="`folder_${f.id}`"
class="doc-card folder-card"
:title="`${f.name}(文件夹)`"
@click="enterFolder(f)"
>
<span class="doc-card-icon folder-card-icon">
<el-icon><FolderOpened /></el-icon>
</span>
<span class="doc-card-name">{{ f.name }}</span>
<span class="doc-card-meta">{{ f.doc_count > 0 ? `${f.doc_count} 个文件` : "文件夹" }}</span>
</div>
<div
v-for="row in list"
:key="row.id"
:key="`doc_${row.id}`"
class="doc-card"
:title="`${row.file_name || row.title || '-'}(${formatFileSize(row.size)})`"
>
@@ -144,7 +178,7 @@
</div>
</div>
<el-empty
v-if="!list.length && !loading"
v-if="!list.length && !subFolders.length && !loading"
description="暂无项目文档"
:image-size="60"
class="grid-empty"
@@ -282,6 +316,33 @@ function findFolder(nodes, id) {
return null;
}
/**
* 当前选中文件夹下的子文件夹(与同级文件一起展示)。
* 例:点击二级文件夹后,里面的子文件夹与文件同级,两者一并列出(列表/图标视图均如此)。
*/
const subFolders = computed(() => {
const node = findFolder(folderTree.value, selectedFolder.value);
return node?.children || [];
});
/** 列表视图数据:子文件夹在前(点击进入),其后为当前页文件 */
const listRows = computed(() => [
...subFolders.value.map((f) => ({
...f,
__isFolder: true,
creator_name: "-", // 文件夹行占位,避免各列显示异常
})),
...list.value.map((r) => ({ ...r, __isFolder: false })),
]);
/** 进入(浏览)子文件夹:与左侧树点击行为一致 */
function enterFolder(folder) {
if (!folder?.id || folder.id === selectedFolder.value) return;
selectedFolder.value = folder.id;
pagination.page = 1;
reload();
}
/** 左侧树节点点击:切换筛选文件夹(回到第一页) */
function handleNodeClick(data) {
if (!data.id || data.id === selectedFolder.value) return;
@@ -554,18 +615,19 @@ defineExpose({ reload });
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tree-node-count {
flex-shrink: 0;
min-width: 16px;
padding: 0 5px;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
line-height: 16px;
text-align: center;
}
/* 文件夹数量角标:左侧树与右侧列表(子文件夹行)共用 */
.tree-node-count {
flex-shrink: 0;
min-width: 16px;
padding: 0 5px;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
line-height: 16px;
text-align: center;
}
/* 右侧:文档列表 */
@@ -630,6 +692,24 @@ defineExpose({ reload });
}
}
/* 列表视图:子文件夹名称(点击进入) */
.folder-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--el-text-color-primary);
font-weight: 500;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
.folder-link-icon {
color: var(--el-color-warning);
}
}
/* 分页:列表 / 图标视图共用 */
.docs-pagination {
display: flex;
@@ -707,6 +787,20 @@ defineExpose({ reload });
}
}
/* 文件夹卡片:点击进入该文件夹 */
.folder-card {
cursor: pointer;
&:hover .doc-card-name {
color: var(--el-color-primary);
}
.folder-card-icon {
display: inline-flex;
color: var(--el-color-warning);
}
}
.grid-empty {
grid-column: 1 / -1;
}