优化合同个管理和产品管理相关功能
This commit is contained in:
@@ -8,6 +8,18 @@
|
||||
@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">
|
||||
{{ overdueText(projectOverdue(project).days) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<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">
|
||||
@@ -80,6 +92,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete } from "@element-plus/icons-vue";
|
||||
import { deleteProject } from "@/api/crmPipeline";
|
||||
import DetailBasic from "./detail_basic.vue";
|
||||
import DetailContact from "./detail_contact.vue";
|
||||
import DetailFollow from "./detail_follow.vue";
|
||||
@@ -87,12 +102,13 @@ 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 { projectStatusText, projectStatusTag, projectOverdue, overdueText } from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
project: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "refresh"]);
|
||||
const emit = defineEmits(["update:visible", "refresh", "edit", "delete"]);
|
||||
|
||||
// 关联类型:3=项目
|
||||
const RELATED_TYPE = 3;
|
||||
@@ -125,6 +141,24 @@ function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
emit("edit", props.project);
|
||||
}
|
||||
|
||||
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 数据 */
|
||||
function loadAll() {
|
||||
if (!props.project?.id) return;
|
||||
@@ -145,6 +179,27 @@ defineExpose({ loadAll });
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -1,15 +1,73 @@
|
||||
<template>
|
||||
<div class="detail-attach">
|
||||
<div class="tab-toolbar">
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
|
||||
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
|
||||
</el-upload>
|
||||
<span v-if="tip" class="tip">{{ tip }}</span>
|
||||
<div class="toolbar-left">
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
|
||||
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
|
||||
</el-upload>
|
||||
<span v-if="tip" class="tip">{{ tip }}</span>
|
||||
</div>
|
||||
<el-radio-group v-model="viewMode" size="small" class="view-switch">
|
||||
<el-radio-button value="grid">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="list">
|
||||
<el-icon><Menu /></el-icon>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||
|
||||
<!-- 图标模式 -->
|
||||
<div v-if="viewMode === 'grid'" v-loading="loading" class="attach-grid">
|
||||
<div
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="attach-card"
|
||||
@click="openPreview(row)"
|
||||
>
|
||||
<div class="attach-icon" :class="fileKind(row)">
|
||||
<el-icon :size="32"><component :is="fileIcon(row)" /></el-icon>
|
||||
</div>
|
||||
<div class="attach-name" :title="row.file_name || '-'">
|
||||
{{ row.file_name || "-" }}
|
||||
</div>
|
||||
<div class="attach-meta">
|
||||
<span class="attach-size">{{ formatFileSize(row.file_size) }}</span>
|
||||
<el-tag
|
||||
v-if="showStage"
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="pipelineStageTag(row.related_type)"
|
||||
>
|
||||
{{ pipelineStageText(row.related_type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="attach-sub">
|
||||
<span class="attach-user" :title="row.uploader_name">{{ row.uploader_name || "-" }}</span>
|
||||
<span class="attach-time">{{ formatDateTime(row.create_time) }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
class="attach-del"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
@click.stop="handleDelete(row)"
|
||||
/>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!list.length && !loading"
|
||||
description="暂无附件"
|
||||
:image-size="60"
|
||||
class="attach-empty"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表模式 -->
|
||||
<el-table v-else :data="list" v-loading="loading" stripe border size="small">
|
||||
<el-table-column label="文件名" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<a :href="row.file_url" target="_blank" class="file-link">{{ row.file_name || "-" }}</a>
|
||||
<span class="file-link" @click="openPreview(row)">{{ row.file_name || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="showStage" label="阶段" min-width="62" align="center">
|
||||
@@ -26,20 +84,120 @@
|
||||
<el-table-column label="上传时间" min-width="140">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="76" align="center">
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openPreview(row)">预览</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="attach-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 40, 60, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
size="small"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 在线预览 -->
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
:title="previewRow?.file_name || '附件预览'"
|
||||
width="900px"
|
||||
top="6vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="attach-preview-dialog"
|
||||
>
|
||||
<div class="preview-body">
|
||||
<div
|
||||
v-if="previewType === 'image'"
|
||||
class="preview-zoom"
|
||||
:class="{ 'is-dragging': dragging }"
|
||||
@wheel.prevent="onWheel"
|
||||
@mousedown="onDragStart"
|
||||
@mousemove="onDragMove"
|
||||
@mouseup="onDragEnd"
|
||||
@mouseleave="onDragEnd"
|
||||
>
|
||||
<img
|
||||
:src="previewRow.file_url"
|
||||
class="preview-zoom-img"
|
||||
:style="imgStyle"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="preview-zoom-bar" @mousedown.stop @wheel.stop>
|
||||
<el-button link size="small" :icon="ZoomOut" @click="stepZoom(-0.2)" />
|
||||
<span class="zoom-text">{{ Math.round(zoom * 100) }}%</span>
|
||||
<el-button link size="small" :icon="ZoomIn" @click="stepZoom(0.2)" />
|
||||
<el-button link size="small" :icon="RefreshRight" @click="resetZoom" />
|
||||
</div>
|
||||
</div>
|
||||
<video
|
||||
v-else-if="previewType === 'video'"
|
||||
:src="previewRow.file_url"
|
||||
controls
|
||||
class="preview-video"
|
||||
></video>
|
||||
<audio
|
||||
v-else-if="previewType === 'audio'"
|
||||
:src="previewRow.file_url"
|
||||
controls
|
||||
class="preview-audio"
|
||||
></audio>
|
||||
<iframe
|
||||
v-else-if="previewType === 'pdf'"
|
||||
:src="previewRow.file_url"
|
||||
class="preview-frame"
|
||||
></iframe>
|
||||
<iframe v-else-if="previewType === 'office'" :src="officeUrl" class="preview-frame"></iframe>
|
||||
<iframe
|
||||
v-else-if="previewType === 'frame'"
|
||||
:src="previewRow.file_url"
|
||||
class="preview-frame"
|
||||
></iframe>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该文件类型暂不支持在线预览,请下载后查看"
|
||||
:image-size="80"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :icon="Download" @click="downloadFile(previewRow)">下载</el-button>
|
||||
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Upload } from "@element-plus/icons-vue";
|
||||
import {
|
||||
Upload,
|
||||
Grid,
|
||||
Menu,
|
||||
Delete,
|
||||
Download,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RefreshRight,
|
||||
Document,
|
||||
Picture,
|
||||
VideoCamera,
|
||||
Headset,
|
||||
FolderOpened,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { getAttachList, addAttach, deleteAttach } from "@/api/crmPipeline";
|
||||
import { uploadFile } from "@/api/file";
|
||||
import { formatDateTime, pipelineStageText, pipelineStageTag } from "../../dict";
|
||||
@@ -61,11 +219,82 @@ const emit = defineEmits(["count"]);
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const list = ref([]);
|
||||
// 视图模式:grid=图标模式(默认,便于快速查找) / list=列表模式
|
||||
const viewMode = ref("grid");
|
||||
// 分页:默认每页 20 条
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
// 预览
|
||||
const previewVisible = ref(false);
|
||||
const previewRow = ref({});
|
||||
// 图片缩放/拖拽
|
||||
const zoom = ref(1);
|
||||
const panX = ref(0);
|
||||
const panY = ref(0);
|
||||
const dragging = ref(false);
|
||||
const dragStart = { x: 0, y: 0, px: 0, py: 0 };
|
||||
const ZOOM_MIN = 0.2;
|
||||
const ZOOM_MAX = 8;
|
||||
|
||||
const imgStyle = computed(() => ({
|
||||
transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`,
|
||||
}));
|
||||
|
||||
function clampPan() {
|
||||
// 未放大时不允许拖动偏移
|
||||
if (zoom.value <= 1) {
|
||||
panX.value = 0;
|
||||
panY.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setZoom(v) {
|
||||
zoom.value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Number(v.toFixed(2))));
|
||||
clampPan();
|
||||
}
|
||||
|
||||
function stepZoom(delta) {
|
||||
setZoom(zoom.value + delta);
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
zoom.value = 1;
|
||||
panX.value = 0;
|
||||
panY.value = 0;
|
||||
}
|
||||
|
||||
/** 滚轮缩放(向上放大、向下缩小) */
|
||||
function onWheel(e) {
|
||||
const factor = e.deltaY < 0 ? 1.12 : 0.89;
|
||||
setZoom(zoom.value * factor);
|
||||
}
|
||||
|
||||
function onDragStart(e) {
|
||||
if (zoom.value <= 1) return;
|
||||
dragging.value = true;
|
||||
dragStart.x = e.clientX;
|
||||
dragStart.y = e.clientY;
|
||||
dragStart.px = panX.value;
|
||||
dragStart.py = panY.value;
|
||||
}
|
||||
|
||||
function onDragMove(e) {
|
||||
if (!dragging.value) return;
|
||||
panX.value = dragStart.px + (e.clientX - dragStart.x);
|
||||
panY.value = dragStart.py + (e.clientY - dragStart.y);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||
async function reload() {
|
||||
if (!props.relatedId) {
|
||||
list.value = [];
|
||||
total.value = 0;
|
||||
emit("count", 0);
|
||||
return;
|
||||
}
|
||||
@@ -74,16 +303,41 @@ async function reload() {
|
||||
const params = {
|
||||
related_type: props.relatedType,
|
||||
related_id: props.relatedId,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (props.includeSource) params.include_source = 1;
|
||||
const res = await getAttachList(params);
|
||||
list.value = res?.data?.list || [];
|
||||
const data = res?.data || {};
|
||||
const rawList = data.list || [];
|
||||
total.value = Number(data.total) || rawList.length;
|
||||
// 兜底:后端若未按分页返回(条数超过每页),前端本地切片,保证一页数量正确
|
||||
if (rawList.length > pageSize.value) {
|
||||
const start = (page.value - 1) * pageSize.value;
|
||||
list.value = rawList.slice(start, start + pageSize.value);
|
||||
} else {
|
||||
list.value = rawList;
|
||||
}
|
||||
} catch (e) {
|
||||
list.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
emit("count", list.value.length);
|
||||
emit("count", total.value);
|
||||
}
|
||||
|
||||
/** 切换页码 */
|
||||
function handlePageChange(p) {
|
||||
page.value = p;
|
||||
reload();
|
||||
}
|
||||
|
||||
/** 切换每页条数(回到第一页) */
|
||||
function handleSizeChange(s) {
|
||||
pageSize.value = s;
|
||||
page.value = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
async function handleUpload(options) {
|
||||
@@ -103,6 +357,7 @@ async function handleUpload(options) {
|
||||
file_size: file.size || 0,
|
||||
});
|
||||
ElMessage.success("上传成功");
|
||||
page.value = 1;
|
||||
reload();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "上传失败");
|
||||
@@ -116,6 +371,8 @@ async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
|
||||
await deleteAttach({ id: row.id });
|
||||
ElMessage.success("删除成功");
|
||||
// 删除当前页最后一条时回退一页,避免出现空白页
|
||||
if (list.value.length === 1 && page.value > 1) page.value -= 1;
|
||||
reload();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
@@ -129,6 +386,101 @@ function formatFileSize(size) {
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** 取文件扩展名(小写,不含点) */
|
||||
function extOf(name) {
|
||||
const n = String(name || "");
|
||||
const i = n.lastIndexOf(".");
|
||||
return i > -1 ? n.slice(i + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
|
||||
const VIDEO_EXTS = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"];
|
||||
const AUDIO_EXTS = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"];
|
||||
const ARCHIVE_EXTS = ["zip", "rar", "7z", "tar", "gz", "bz2"];
|
||||
const OFFICE_EXTS = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"];
|
||||
const FRAME_EXTS = [
|
||||
"txt",
|
||||
"md",
|
||||
"json",
|
||||
"log",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"js",
|
||||
"ts",
|
||||
"csv",
|
||||
"yml",
|
||||
"yaml",
|
||||
"ini",
|
||||
"conf",
|
||||
];
|
||||
|
||||
/** 按扩展名归类,用于图标配色 */
|
||||
function fileKind(row) {
|
||||
const ext = extOf(row.file_name || row.file_url);
|
||||
if (IMAGE_EXTS.includes(ext)) return "is-image";
|
||||
if (VIDEO_EXTS.includes(ext)) return "is-video";
|
||||
if (AUDIO_EXTS.includes(ext)) return "is-audio";
|
||||
if (ARCHIVE_EXTS.includes(ext)) return "is-archive";
|
||||
if (ext === "pdf") return "is-pdf";
|
||||
if (["doc", "docx"].includes(ext)) return "is-word";
|
||||
if (["xls", "xlsx", "csv"].includes(ext)) return "is-excel";
|
||||
if (["ppt", "pptx"].includes(ext)) return "is-ppt";
|
||||
return "is-file";
|
||||
}
|
||||
|
||||
/** 按扩展名返回文件图标组件 */
|
||||
function fileIcon(row) {
|
||||
switch (fileKind(row)) {
|
||||
case "is-image":
|
||||
return Picture;
|
||||
case "is-video":
|
||||
return VideoCamera;
|
||||
case "is-audio":
|
||||
return Headset;
|
||||
case "is-archive":
|
||||
return FolderOpened;
|
||||
default:
|
||||
return Document;
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览类型:image / video / audio / pdf / office / frame(可 iframe 直出)/ none */
|
||||
function previewTypeOf(row) {
|
||||
const ext = extOf(row.file_name || row.file_url);
|
||||
if (IMAGE_EXTS.includes(ext)) return "image";
|
||||
if (VIDEO_EXTS.includes(ext)) return "video";
|
||||
if (AUDIO_EXTS.includes(ext)) return "audio";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (OFFICE_EXTS.includes(ext)) return "office";
|
||||
if (FRAME_EXTS.includes(ext)) return "frame";
|
||||
return "none";
|
||||
}
|
||||
|
||||
const previewType = computed(() =>
|
||||
previewRow.value ? previewTypeOf(previewRow.value) : "none"
|
||||
);
|
||||
|
||||
// Office 文档通过微软在线预览服务渲染(需要文件 URL 可被公网访问)
|
||||
const officeUrl = computed(() => {
|
||||
const url = previewRow.value?.file_url || "";
|
||||
if (!url) return "";
|
||||
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
|
||||
});
|
||||
|
||||
/** 打开在线预览 */
|
||||
function openPreview(row) {
|
||||
previewRow.value = row || {};
|
||||
resetZoom();
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
/** 下载文件(新窗口打开原始地址) */
|
||||
function downloadFile(row) {
|
||||
if (row?.file_url) window.open(row.file_url, "_blank");
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
@@ -136,21 +488,241 @@ defineExpose({ reload });
|
||||
.tab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
:deep(.el-radio-button__inner) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-link {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图标模式网格 */
|
||||
.attach-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 120px;
|
||||
|
||||
.attach-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.attach-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.attach-del {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.attach-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: var(--el-text-color-placeholder);
|
||||
|
||||
&.is-image {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
&.is-video {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
&.is-audio {
|
||||
background: #14b8a6;
|
||||
}
|
||||
|
||||
&.is-archive {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
&.is-pdf {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
&.is-word {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
&.is-excel {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
&.is-ppt {
|
||||
background: #f97316;
|
||||
}
|
||||
}
|
||||
|
||||
.attach-name {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attach-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.attach-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
|
||||
.attach-user,
|
||||
.attach-time {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.attach-del {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.attach-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 在线预览 */
|
||||
.preview-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
max-height: 74vh;
|
||||
overflow: auto;
|
||||
|
||||
.preview-zoom {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 72vh;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-lighter);
|
||||
cursor: grab;
|
||||
|
||||
&.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.preview-zoom-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.06s linear;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.preview-zoom-bar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
|
||||
.zoom-text {
|
||||
min-width: 42px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
color: #fff;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 72vh;
|
||||
}
|
||||
|
||||
.preview-audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 72vh;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,17 +26,17 @@
|
||||
<div class="pane-head">
|
||||
<div class="pane-title">
|
||||
<span class="name">{{ c.contract_name }}</span>
|
||||
<el-tag :type="contractStatusTag(c.status)" size="small">
|
||||
<!-- <el-tag :type="contractStatusTag(c.status)" size="small">
|
||||
{{ contractStatusText(c.status) }}
|
||||
</el-tag>
|
||||
</el-tag> -->
|
||||
<el-tag type="primary" size="small" effect="plain">
|
||||
{{ ourRoleText(c.our_role) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="pane-actions">
|
||||
<!-- <div class="pane-actions">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(c)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(c)">删除</el-button>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="pane-sub">
|
||||
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip fixed>
|
||||
<el-table-column label="状态" width="100" align="center" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip >
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.project_name }}</span>
|
||||
</template>
|
||||
@@ -47,9 +52,12 @@
|
||||
<el-table-column label="项目金额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<el-table-column label="逾期" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
|
||||
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||||
{{ overdueText(projectOverdue(row).days) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始日期" width="120" align="center">
|
||||
@@ -64,21 +72,6 @@
|
||||
<el-table-column label="对接人" width="100">
|
||||
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="210" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<!-- <el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!row.customer_id"
|
||||
@click="openContactBook(row)"
|
||||
>
|
||||
通讯录
|
||||
</el-button> -->
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无项目" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
@@ -105,15 +98,21 @@
|
||||
:company-name="contactBookCompanyName"
|
||||
/>
|
||||
|
||||
<ProjectDetail v-model:visible="detailVisible" :project="currentRow" @refresh="fetchList" />
|
||||
<ProjectDetail
|
||||
v-model:visible="detailVisible"
|
||||
:project="currentRow"
|
||||
@refresh="fetchList"
|
||||
@edit="onDetailEdit"
|
||||
@delete="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getProjectList, deleteProject } from "@/api/crmPipeline";
|
||||
import { getProjectList } from "@/api/crmPipeline";
|
||||
import ProjectEdit from "./components/edit.vue";
|
||||
import ProjectDetail from "./components/detail.vue";
|
||||
import ContactBook from "../../erp/components/contactBook.vue";
|
||||
@@ -121,6 +120,8 @@ import {
|
||||
PROJECT_STATUS_OPTIONS,
|
||||
projectStatusText,
|
||||
projectStatusTag,
|
||||
projectOverdue,
|
||||
overdueText,
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
} from "../dict";
|
||||
@@ -188,6 +189,12 @@ function openDetail(row) {
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
/** 详情内触发编辑:关闭详情抽屉,打开编辑弹窗 */
|
||||
function onDetailEdit(row) {
|
||||
detailVisible.value = false;
|
||||
openEdit(row || currentRow.value);
|
||||
}
|
||||
|
||||
function openContactBook(row) {
|
||||
if (!row.customer_id) {
|
||||
ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
|
||||
@@ -197,19 +204,6 @@ function openContactBook(row) {
|
||||
contactBookCompanyName.value = row.customer_name || "";
|
||||
contactBookVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除项目「${row.project_name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteProject(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
|
||||
Reference in New Issue
Block a user