Files
yunzerwebsiteallinone/backend/src/views/apps/crm/project/components/detail.vue
T
2026-09-29 18:25:09 +08:00

322 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-drawer
:model-value="visible"
:title="project?.project_name || '项目详情'"
direction="rtl"
size="1200px"
@update:model-value="handleClose"
@opened="loadAll"
>
<div v-if="project" class="project-detail">
<div class="detail-toolbar">
<div class="toolbar-status">
<el-tag :type="projectStatusTag(project.status)" size="small">{{ projectStatusText(project.status) }}</el-tag>
<el-tag v-if="projectOverdue(project).overdue" type="danger" size="small" effect="dark">
{{ projectOverdue(project).text }}
</el-tag>
</div>
<div class="toolbar-actions">
<!-- 状态流转:与合同详情一致,右上角下拉切换项目状态 -->
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(cmd)">
<el-button link type="warning" size="small">
状态流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in statusOptionsExcept(project.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
<DetailBasic :project="project" />
</el-tab-pane>
<!-- 合同管理(一个项目可关联多份合同,横向 Tab 切换查看) -->
<el-tab-pane :label="`合同管理 (${counts.contract})`" name="contract">
<DetailContract
ref="contractRef"
:project-id="project.id"
:project-name="project.project_name || ''"
:project-type="project.project_type"
@count="counts.contract = $event"
/>
</el-tab-pane>
<!-- 跟进记录(链路累积:线索 + 商机 + 项目) -->
<el-tab-pane :label="`跟进记录 (${counts.follow})`" name="follow">
<DetailFollow
ref="followRef"
:related-id="project.id"
:related-type="RELATED_TYPE"
:related-name="project.project_name || ''"
:include-source="true"
@count="counts.follow = $event"
@saved="emit('refresh')"
/>
</el-tab-pane>
<!-- 联系人(按项目类型分组:从对应企业通讯录中挑选,支持多选) -->
<el-tab-pane :label="`联系人 (${counts.contact})`" name="contact">
<DetailContact ref="contactRef" :project="project" @count="counts.contact = $event" />
</el-tab-pane>
<!-- 项目文档(同步到文档库:共享文档 / 项目文档 / {项目名称}) -->
<el-tab-pane :label="`项目文档 (${counts.docs})`" name="docs">
<DetailDocs
ref="docsRef"
:project-id="project.id"
:project-name="project.project_name || ''"
@count="counts.docs = $event"
/>
</el-tab-pane>
<!-- 附件(链路累积:线索 + 商机 + 项目) -->
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
<DetailAttach
ref="attachRef"
:related-id="project.id"
:related-type="RELATED_TYPE"
:include-source="true"
:show-stage="true"
@count="counts.attach = $event"
/>
</el-tab-pane>
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="log">
<DetailLog ref="logRef" :related-id="project.id" :related-type="RELATED_TYPE" />
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import { deleteProject, changeProjectStatus, getProjectDetail } from "@/api/crmPipeline";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
import DetailContract from "./detail_contract.vue";
import DetailAttach from "./detail_attach.vue";
import DetailDocs from "./detail_docs.vue";
import DetailLog from "./detail_log.vue";
import {
PROJECT_STATUS_OPTIONS,
projectStatusText,
projectStatusTag,
projectOverdue,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
project: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "refresh", "edit", "delete"]);
// 关联类型:3=项目
const RELATED_TYPE = 3;
const activeTab = ref("basic");
const counts = reactive({ contact: 0, follow: 0, contract: 0, attach: 0, docs: 0 });
const contactRef = ref();
const followRef = ref();
const contractRef = ref();
const attachRef = ref();
const docsRef = ref();
const logRef = ref();
watch(
() => props.visible,
(val) => {
if (val) {
activeTab.value = "basic";
counts.contact = 0;
counts.follow = 0;
counts.contract = 0;
counts.attach = 0;
counts.docs = 0;
}
}
);
function handleClose() {
emit("update:visible", false);
}
function handleEdit() {
emit("edit", props.project);
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(cur) {
return PROJECT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
/**
* 项目状态流转:成功后更新本地展示并通知父组件刷新列表。
* 置为「异常」时先询问原因,原因会写入操作日志,便于追溯是什么导致的。
*/
async function handleChangeStatus(status) {
if (!props.project?.id) return;
let reason = "";
if (Number(status) === 5) {
try {
const { value } = await ElMessageBox.prompt(
"请填写项目异常原因,该原因会记入操作日志",
"异常原因",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
inputPlaceholder: "如:客户预算取消 / 供应商延期交付",
inputValidator: (v) => (String(v || "").trim() ? true : "异常原因不能为空"),
}
);
reason = String(value || "").trim();
} catch (e) {
return; // 用户取消:不流转状态
}
}
try {
await changeProjectStatus(props.project.id, Number(status), reason);
ElMessage.success(`状态已更新为「${projectStatusText(status)}」`);
props.project.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
async function handleDelete() {
try {
await ElMessageBox.confirm(`确定删除项目「${props.project.project_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProject(props.project.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("delete");
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
/** 抽屉打开后刷新各 Tab 数据 */
async function loadAll() {
if (!props.project?.id) return;
// 列表行数据是打开页面时的快照,联系人选中集合等字段可能已过期(如刚在其他项目/本次会话保存过),
// 先拉取最新详情刷新快照,各 Tab(含联系人)再按最新数据加载
try {
const res = await getProjectDetail(props.project.id);
const data = res?.data;
if (data && typeof data === "object" && props.project) {
Object.assign(props.project, data);
}
} catch (e) {
// 详情接口失败时保持快照展示,不阻断其他 Tab
}
contactRef.value?.reload();
followRef.value?.reload();
contractRef.value?.reload();
attachRef.value?.reload();
docsRef.value?.reload();
logRef.value?.reload();
}
defineExpose({ loadAll });
</script>
<style lang="less" scoped>
:deep(.el-drawer__body) {
overflow-x: hidden;
}
.project-detail {
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.toolbar-status {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.detail-tabs {
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
display: flex;
width: 100%;
min-width: 0;
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应。
// 注意不能用 overflow: hidden——它会把附件/日志等较高的内容直接截断,
// 让内容自然撑高,滚动交给 drawer 自身(el-drawer__body)处理。
:deep(.el-tabs__content) {
flex: 1;
min-width: 0;
overflow: visible;
padding-left: 4px;
}
:deep(.el-tabs__header) {
margin-right: 12px;
flex-shrink: 0;
}
:deep(.el-tabs__item) {
justify-content: flex-start;
text-align: left;
padding: 0 12px;
min-width: 96px;
font-size: 13px;
}
:deep(.el-tab-pane) {
width: 100%;
min-width: 0;
}
:deep(.el-table) {
width: 100%;
// 操作列:链接按钮去掉自带内边距、缩小间距,避免「编辑/删除」被挤成两行
.el-button.is-link {
padding: 0;
height: auto;
min-height: auto;
}
.el-button + .el-button {
margin-left: 6px;
}
}
}
}
</style>