优化
This commit is contained in:
Generated
+2353
-1
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,11 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.7.7",
|
||||
"compressorjs": "^1.3.0",
|
||||
"element-plus": "^2.8.4",
|
||||
@@ -18,8 +20,13 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"happy-dom": "^20.10.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,12 +716,23 @@ export const fetchTemplates = (params?: {
|
||||
status?: string;
|
||||
scopeType?: string;
|
||||
bizScene?: string;
|
||||
watermarkEnabled?: boolean;
|
||||
|
||||
effectiveStatus?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
}) =>
|
||||
http.get("/templates", { params });
|
||||
export const fetchTemplateViewList = (params?: {
|
||||
templateName?: string;
|
||||
templateType?: string;
|
||||
scopeType?: string;
|
||||
bizScene?: string;
|
||||
|
||||
effectiveStatus?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
}) =>
|
||||
http.get("/templates/view-list", { params });
|
||||
export const fetchPublishedTemplateOptions = (params?: { bizScene?: string }) =>
|
||||
http.get("/templates/published-options", { params });
|
||||
export const fetchTemplateTypeOptions = () => http.get("/templates/type-options");
|
||||
@@ -742,9 +753,21 @@ export const createTemplate = (payload: {
|
||||
changeLog?: string;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
watermarkEnabled?: boolean;
|
||||
|
||||
downloadRateLimitPerHour?: number;
|
||||
}) => http.post("/templates", payload);
|
||||
export const updateTemplate = (id: number, payload: {
|
||||
templateName: string;
|
||||
templateType: string;
|
||||
scopeType: "ALL" | "PROJECT" | "MEETING";
|
||||
projectId?: number;
|
||||
meetingId?: number;
|
||||
bizScene?: "MEETING_RECOMMEND" | "AUDIT_NOTIFY" | "SETTLEMENT";
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
|
||||
downloadRateLimitPerHour?: number;
|
||||
}) => http.post(`/templates/${id}/update`, payload);
|
||||
export const fetchTemplateUploadSign = (payload: {
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
@@ -764,8 +787,6 @@ export const archiveTemplate = (id: number) => http.post(`/templates/${id}/archi
|
||||
export const rollbackTemplate = (id: number, payload: { versionNo: number; rollbackReason: string }) =>
|
||||
http.post(`/templates/${id}/rollback`, payload);
|
||||
export const downloadTemplate = (id: number) => http.get(`/templates/${id}/download`);
|
||||
export const downloadTemplateWatermark = (id: number, params?: { watermarkText?: string }) =>
|
||||
http.get(`/templates/${id}/download-watermark`, { params });
|
||||
export const fetchTemplateVersionDiff = (id: number, params?: { leftVersionNo?: number; rightVersionNo?: number }) =>
|
||||
http.get(`/templates/${id}/versions/diff`, { params });
|
||||
export const fetchTemplateDownloadLogs = (params?: {
|
||||
@@ -774,7 +795,7 @@ export const fetchTemplateDownloadLogs = (params?: {
|
||||
userId?: number;
|
||||
userKeyword?: string;
|
||||
versionNo?: number;
|
||||
downloadType?: "NORMAL" | "WATERMARK";
|
||||
|
||||
ip?: string;
|
||||
downloadedFrom?: string;
|
||||
downloadedTo?: string;
|
||||
|
||||
@@ -76,6 +76,7 @@ const handleMarkRead = () => {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.notif-detail-title {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PERMS = {
|
||||
read: "meeting.read",
|
||||
manage: "meeting.manage",
|
||||
create: "meeting.create",
|
||||
update: "meeting.update",
|
||||
delete: "meeting.delete",
|
||||
cancel: "meeting.cancel",
|
||||
submit: "meeting.submit",
|
||||
@@ -83,6 +84,7 @@ export const PERMS = {
|
||||
read: "template.read",
|
||||
manage: "template.manage",
|
||||
create: "template.create",
|
||||
update: "template.update",
|
||||
publish: "template.publish",
|
||||
disable: "template.disable",
|
||||
archive: "template.archive",
|
||||
@@ -90,6 +92,7 @@ export const PERMS = {
|
||||
download: "template.download",
|
||||
downloadLogReadAll: "template.download.log.read.all",
|
||||
flowLink: "template.flow.link",
|
||||
detailRead: "template.detail.read",
|
||||
},
|
||||
expert: {
|
||||
read: "expert.read",
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
import * as xlsx from "xlsx";
|
||||
|
||||
export const readTextFile = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result as ArrayBuffer;
|
||||
try {
|
||||
const decoderUtf8 = new TextDecoder("utf-8", { fatal: true });
|
||||
resolve(decoderUtf8.decode(buffer));
|
||||
} catch (e) {
|
||||
try {
|
||||
const decoderGbk = new TextDecoder("gbk");
|
||||
resolve(decoderGbk.decode(buffer));
|
||||
} catch (e2) {
|
||||
reject(new Error("文件编码无法识别"));
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsText(file, "utf-8");
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
export const readXlsxFile = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
// codepage 936 tells xlsx to use GBK for CSVs misnamed as XLS
|
||||
const workbook = xlsx.read(data, { type: "array", codepage: 936 });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const csv = xlsx.utils.sheet_to_csv(worksheet);
|
||||
resolve(csv);
|
||||
} catch (err) {
|
||||
reject(new Error("读取 Excel 文件失败"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const downloadCsvTemplate = (fileName: string, headers: string[], sampleRows: string[][] = []) => {
|
||||
downloadCsvRows(fileName, [headers, ...sampleRows]);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
AUDIT_RETURNED: "审核退回",
|
||||
FINANCE_CONFIRMED: "财务已确认",
|
||||
USER_CREATED: "用户创建",
|
||||
USER_PASSWORD_RESET: "密码重置",
|
||||
DELIVERED: "已送达",
|
||||
UNREAD: "未读",
|
||||
READ: "已读",
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="dashboard">工作台</el-dropdown-item>
|
||||
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出全部设备</el-dropdown-item>
|
||||
<el-dropdown-item command="refreshAuth">刷新权限</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -159,7 +160,8 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="dashboard">工作台</el-dropdown-item>
|
||||
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出全部设备</el-dropdown-item>
|
||||
<el-dropdown-item command="refreshAuth">刷新权限</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -281,7 +283,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import BreadcrumbNav from "../../components/BreadcrumbNav.vue";
|
||||
import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue";
|
||||
import InAppNotificationDetailDialog from "../../components/InAppNotificationDetailDialog.vue";
|
||||
import { fetchInAppNotifications, markAllInAppNotificationsRead, markInAppNotificationRead, logoutAllAuth, switchTenant } from "../../api/modules";
|
||||
import { fetchInAppNotifications, markAllInAppNotificationsRead, markInAppNotificationRead, logoutAllAuth, switchTenant, refreshAuth } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAppearanceStore } from "../../stores/appearance";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
@@ -554,6 +556,19 @@ const handleUserCommand = async (command: string) => {
|
||||
await router.push(profileRoute.value);
|
||||
return;
|
||||
}
|
||||
if (command === "refreshAuth") {
|
||||
try {
|
||||
const resp = await refreshAuth();
|
||||
withSuppressedAuthRefresh(() => {
|
||||
authStore.saveAuthPayload(resp?.data || null);
|
||||
});
|
||||
await refreshLayoutState();
|
||||
ElMessage.success("权限刷新成功");
|
||||
} catch (e) {
|
||||
// Ignore, handled by global interceptor
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "logout") {
|
||||
await logoutAll();
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300">
|
||||
<el-table-column label="操作" width="380" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="handleCopy(row.id)">复制</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="handleEdit(row)">编辑节点</el-button>
|
||||
|
||||
@@ -1266,7 +1266,7 @@ const resetMaterialViews = () => {
|
||||
meetingEffect: "",
|
||||
};
|
||||
docView.value = { agendas: [], signInName: "", signInOssKey: "", themePhotoName: "", themePhotoOssKey: "", invitations: [] };
|
||||
photoView.value = { photos: [], summary: "" };
|
||||
photoView.value = { photos: [], summary: "", expertSummaries: [] };
|
||||
laborView.value = { details: [] };
|
||||
invoiceView.value = { invoices: [], summary: "" };
|
||||
expertProfileView.value = { fileName: "", ossKey: "" };
|
||||
@@ -2084,6 +2084,7 @@ const loadMaterial = async () => {
|
||||
originIndex: index + 1,
|
||||
})),
|
||||
summary: onsitePhoto.summary || "",
|
||||
expertSummaries: Array.isArray(onsitePhoto.expertSummaries) ? onsitePhoto.expertSummaries : [],
|
||||
};
|
||||
laborView.value = {
|
||||
details: (Array.isArray(laborProtocol.details) ? laborProtocol.details : []).map((x: any) => {
|
||||
|
||||
@@ -198,13 +198,13 @@
|
||||
|
||||
<el-dialog v-model="importVisible" title="批量导入专家" :width="DIALOG_WIDTH.lg" destroy-on-close>
|
||||
<div class="text-secondary mb-sm">
|
||||
支持 `.csv` 或制表符文本。模板字段顺序为:
|
||||
`expertName, idNo, phone, titleCode, hospitalCode`
|
||||
支持 `.xlsx`, `.xls`, `.csv` 或制表符文本。模板字段顺序为:
|
||||
`专家姓名, 身份证号, 手机号, 职称, 医院`
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".csv,.txt"
|
||||
accept=".xlsx,.xls,.csv,.txt"
|
||||
:on-change="handleImportFileChange"
|
||||
>
|
||||
<el-button>选择文件</el-button>
|
||||
@@ -221,11 +221,11 @@
|
||||
<div v-if="importPreview.length" class="mt-md">
|
||||
<div class="mb-sm">预览前 {{ importPreview.length }} 行:</div>
|
||||
<el-table :data="importPreview" size="small" max-height="240">
|
||||
<el-table-column prop="expertName" label="姓名" />
|
||||
<el-table-column prop="idNo" label="身份证" />
|
||||
<el-table-column prop="expertName" label="专家姓名" />
|
||||
<el-table-column prop="idNo" label="身份证号" />
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column prop="titleCode" label="职称编码" />
|
||||
<el-table-column prop="hospitalCode" label="医院编码" />
|
||||
<el-table-column prop="title" label="职称" />
|
||||
<el-table-column prop="organization" label="医院" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="lastImportResult" class="mt-md">
|
||||
@@ -298,7 +298,7 @@ import {
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { downloadCsvRows, downloadCsvTemplate, findMissingHeaders, parseDelimitedText, readTextFile } from "../../utils/batchImport";
|
||||
import { downloadCsvRows, downloadCsvTemplate, findMissingHeaders, parseDelimitedText, readTextFile, readXlsxFile } from "../../utils/batchImport";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isPlatform = computed(() => authStore.scope === "PLATFORM");
|
||||
@@ -320,14 +320,14 @@ const importText = ref("");
|
||||
const importVisible = ref(false);
|
||||
const importLoading = ref(false);
|
||||
const importFileName = ref("");
|
||||
const importPreview = ref<Array<{ expertName: string; idNo: string; phone: string; titleCode: string; hospitalCode: string }>>([]);
|
||||
const importPreview = ref<Array<{ expertName: string; idNo: string; phone: string; title: string; organization: string }>>([]);
|
||||
const lastImportResult = ref<any | null>(null);
|
||||
const lastImportedExperts = ref<Array<{
|
||||
expertName: string;
|
||||
idNo: string;
|
||||
phone: string;
|
||||
titleCode: string;
|
||||
hospitalCode: string;
|
||||
title: string;
|
||||
organization: string;
|
||||
}> | null>(null);
|
||||
const titleOptions = ref<any[]>([]);
|
||||
const hospitalOptions = ref<any[]>([]);
|
||||
@@ -456,13 +456,13 @@ const openImportDialog = () => {
|
||||
|
||||
const downloadExpertTemplate = () => {
|
||||
downloadCsvTemplate("expert-import-template.csv", [
|
||||
"expertName",
|
||||
"idNo",
|
||||
"phone",
|
||||
"titleCode",
|
||||
"hospitalCode",
|
||||
"专家姓名",
|
||||
"身份证号",
|
||||
"手机号",
|
||||
"职称",
|
||||
"医院",
|
||||
], [
|
||||
["张教授", "110101199001011234", "13800138000", "CHIEF_PHYSICIAN", "BEIJING_HOSPITAL"],
|
||||
["张教授", "110101199001011234", "13800138000", "主任医师", "北京医院"],
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -472,7 +472,12 @@ const handleImportFileChange = async (uploadFile: any) => {
|
||||
return;
|
||||
}
|
||||
importFileName.value = file.name;
|
||||
importText.value = await readTextFile(file);
|
||||
const nameLower = file.name.toLowerCase();
|
||||
if (nameLower.endsWith(".xlsx") || nameLower.endsWith(".xls")) {
|
||||
importText.value = await readXlsxFile(file);
|
||||
} else {
|
||||
importText.value = await readTextFile(file);
|
||||
}
|
||||
syncImportPreview();
|
||||
};
|
||||
|
||||
@@ -482,7 +487,7 @@ const parseExpertImportRows = (text: string) => {
|
||||
return [];
|
||||
}
|
||||
const [header, ...dataRows] = rows;
|
||||
const missingHeaders = findMissingHeaders(header, ["expertName", "idNo", "phone"]);
|
||||
const missingHeaders = findMissingHeaders(header, ["专家姓名", "身份证号", "手机号"]);
|
||||
if (missingHeaders.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -493,19 +498,19 @@ const parseExpertImportRows = (text: string) => {
|
||||
return dataRows
|
||||
.filter((row) => row.some((cell) => String(cell || "").trim().length > 0))
|
||||
.map((row) => ({
|
||||
expertName: row[headerIndex.get("expertName") ?? 0] || "",
|
||||
idNo: row[headerIndex.get("idNo") ?? 1] || "",
|
||||
phone: row[headerIndex.get("phone") ?? 2] || "",
|
||||
titleCode: row[headerIndex.get("titleCode") ?? 3] || "",
|
||||
hospitalCode: row[headerIndex.get("hospitalCode") ?? 4] || "",
|
||||
expertName: row[headerIndex.get("专家姓名") ?? 0] || "",
|
||||
idNo: row[headerIndex.get("身份证号") ?? 1] || "",
|
||||
phone: row[headerIndex.get("手机号") ?? 2] || "",
|
||||
title: row[headerIndex.get("职称") ?? 3] || "",
|
||||
organization: row[headerIndex.get("医院") ?? 4] || "",
|
||||
}))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
expertName: item.expertName.trim(),
|
||||
idNo: item.idNo.trim(),
|
||||
phone: item.phone.trim(),
|
||||
titleCode: item.titleCode.trim(),
|
||||
hospitalCode: item.hospitalCode.trim(),
|
||||
title: item.title.trim(),
|
||||
organization: item.organization.trim(),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -516,10 +521,10 @@ const syncImportPreview = () => {
|
||||
const validateExpertImportHeaders = (text: string) => {
|
||||
const rows = parseDelimitedText(text);
|
||||
if (!rows.length) {
|
||||
return { ok: false, missingHeaders: ["expertName", "idNo", "phone"] };
|
||||
return { ok: false, missingHeaders: ["专家姓名", "身份证号", "手机号"] };
|
||||
}
|
||||
const [header] = rows;
|
||||
const missingHeaders = findMissingHeaders(header, ["expertName", "idNo", "phone"]);
|
||||
const missingHeaders = findMissingHeaders(header, ["专家姓名", "身份证号", "手机号"]);
|
||||
return { ok: missingHeaders.length === 0, missingHeaders };
|
||||
};
|
||||
|
||||
@@ -944,11 +949,11 @@ const downloadExpertErrorSnapshot = () => {
|
||||
return;
|
||||
}
|
||||
const rows: string[][] = [[
|
||||
"expertName",
|
||||
"idNo",
|
||||
"phone",
|
||||
"titleCode",
|
||||
"hospitalCode",
|
||||
"专家姓名",
|
||||
"身份证号",
|
||||
"手机号",
|
||||
"职称",
|
||||
"医院",
|
||||
"__error",
|
||||
]];
|
||||
errors.forEach((error: any) => {
|
||||
@@ -961,9 +966,9 @@ const downloadExpertErrorSnapshot = () => {
|
||||
item.expertName || "",
|
||||
item.idNo || "",
|
||||
item.phone || "",
|
||||
item.titleCode || "",
|
||||
item.hospitalCode || "",
|
||||
String(error?.message || ""),
|
||||
item.title || "",
|
||||
item.organization || "",
|
||||
error?.message || "未知错误",
|
||||
]);
|
||||
});
|
||||
downloadCsvRows("expert-import-error-snapshot.csv", rows);
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="taskCode" label="任务编码" width="180" :formatter="taskCodeFormatter" />
|
||||
<el-table-column prop="bizType" label="业务类型" width="140" :formatter="bizTypeFormatter" />
|
||||
<el-table-column prop="bizId" label="业务ID" width="120" />
|
||||
<el-table-column prop="fileName" label="文件名" width="180" />
|
||||
<el-table-column prop="fileOssKey" label="文件OSS" />
|
||||
<!-- <el-table-column prop="bizType" label="业务类型" width="140" :formatter="bizTypeFormatter" /> -->
|
||||
<!-- <el-table-column prop="bizId" label="业务ID" width="120" /> -->
|
||||
<el-table-column prop="fileName" label="文件名" width="350" />
|
||||
<!-- <el-table-column prop="fileOssKey" label="文件OSS" /> -->
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="retryCount" label="重试" width="80" />
|
||||
<el-table-column prop="downloadCount" label="下载次数" width="90" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:rows="rows"
|
||||
:summary-task-map="summaryTaskMap"
|
||||
:can-create="canCreate"
|
||||
:can-edit="canEdit"
|
||||
:can-submit="canSubmit"
|
||||
:can-submit-meeting="canSubmitMeeting"
|
||||
:can-withdraw="canWithdraw"
|
||||
@@ -392,6 +393,7 @@ const meetingPageSize = ref(20);
|
||||
const meetingTotalCount = ref(0);
|
||||
const authStore = useAuthStore();
|
||||
const canCreate = computed(() => authStore.hasPermission(PERMS.meeting.create));
|
||||
const canEdit = computed(() => authStore.hasPermission(PERMS.meeting.update));
|
||||
const canSubmit = computed(() => authStore.hasPermission(PERMS.meeting.submit));
|
||||
const canWithdraw = computed(() => authStore.hasPermission(PERMS.meeting.withdraw));
|
||||
const canMaterialSave = computed(() => authStore.hasPermission(PERMS.meeting.materialSave));
|
||||
@@ -1080,6 +1082,7 @@ const materialBudgetBase = ref({
|
||||
laborTotalCent: 0,
|
||||
invoiceTotalCent: 0,
|
||||
meetingInvoiceTotalCent: 0,
|
||||
cateringTotalCent: 0,
|
||||
ready: false,
|
||||
});
|
||||
const materialModuleLoading = ref(false);
|
||||
@@ -2894,7 +2897,7 @@ const handleGenerateSummary = async (meetingId: number) => {
|
||||
});
|
||||
scheduleSummaryTaskPolling(meetingId, taskId);
|
||||
}
|
||||
ElMessage.success(taskId > 0 ? `会议总结任务已创建:${taskId},当前状态为待处理` : "会议总结任务已创建");
|
||||
ElMessage.success("会议总结生成任务已提交,系统正在后台处理,请稍后可在导出任务中心查看");
|
||||
};
|
||||
|
||||
const handleCheckSummaryStatus = async (meetingId: number) => {
|
||||
@@ -5187,14 +5190,15 @@ const onMeetingInvoiceSectionFileChange = async (
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
|
||||
const section = ensureMeetingInvoiceSection(sectionCode);
|
||||
const shouldRunOcr = fieldKey === "invoiceFile" || sectionCode === "OTHER";
|
||||
section.files[fieldKey].push({
|
||||
fileName: file.name,
|
||||
ossKey: objectKey,
|
||||
ocr: fieldKey === "invoiceFile" ? { status: "idle" } : undefined,
|
||||
ocr: shouldRunOcr ? { status: "idle" } : undefined,
|
||||
});
|
||||
await cacheDocPreviewUrl(objectKey);
|
||||
ElMessage.success("会议发票附件上传成功");
|
||||
if (fieldKey === "invoiceFile") {
|
||||
if (shouldRunOcr) {
|
||||
await runMeetingInvoiceOcr(sectionCode, fieldKey);
|
||||
}
|
||||
} finally {
|
||||
@@ -5615,6 +5619,7 @@ const resetMaterialForms = () => {
|
||||
laborTotalCent: 0,
|
||||
invoiceTotalCent: 0,
|
||||
meetingInvoiceTotalCent: 0,
|
||||
cateringTotalCent: 0,
|
||||
ready: false,
|
||||
};
|
||||
};
|
||||
@@ -6157,127 +6162,7 @@ const loadMaterialModule = async (
|
||||
// ignore when current material does not exist
|
||||
}
|
||||
if (moduleCode === "EXPERT_LIST") {
|
||||
void (async () => {
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
Object.keys(photoByExpert.value).forEach((id) => {
|
||||
const expertId = Number(id || 0);
|
||||
(photoByExpert.value[expertId] || []).forEach((item) => {
|
||||
const nameOrKey = item.name || item.ossKey || "";
|
||||
if (!item.ossKey || !isImageFile(nameOrKey)) {
|
||||
return;
|
||||
}
|
||||
tasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetchFilePresignDownload({ objectKey: item.ossKey });
|
||||
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
|
||||
return;
|
||||
}
|
||||
item.previewUrl = resp?.data?.signedUrl || "";
|
||||
} catch (_e) {
|
||||
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
|
||||
return;
|
||||
}
|
||||
item.previewUrl = "";
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
});
|
||||
if (tasks.length > 0) {
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
Object.keys(laborByExpert.value).forEach((id) => {
|
||||
const expertId = Number(id || 0);
|
||||
const row = laborByExpert.value[expertId];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const invoiceFiles = Array.isArray(row.invoiceFiles) ? row.invoiceFiles : [];
|
||||
if (invoiceFiles.length === 0 && row.invoiceOssKey) {
|
||||
invoiceFiles.push({
|
||||
fileName: String(row.invoiceFileName || "").trim(),
|
||||
ossKey: String(row.invoiceOssKey || "").trim(),
|
||||
previewUrl: "",
|
||||
});
|
||||
}
|
||||
if (invoiceFiles.length === 0) {
|
||||
row.invoicePreviewUrl = "";
|
||||
return;
|
||||
}
|
||||
invoiceFiles.forEach((file, index) => {
|
||||
const key = String(file?.ossKey || "").trim();
|
||||
const nameOrKey = String(file?.fileName || key || "").trim();
|
||||
if (!key || !isImageFile(nameOrKey)) {
|
||||
file.previewUrl = "";
|
||||
if (index === 0) {
|
||||
row.invoicePreviewUrl = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
tasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetchFilePresignDownload({ objectKey: key });
|
||||
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
|
||||
return;
|
||||
}
|
||||
file.previewUrl = resp?.data?.signedUrl || "";
|
||||
if (index === 0) {
|
||||
row.invoicePreviewUrl = file.previewUrl;
|
||||
}
|
||||
} catch (_e) {
|
||||
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
|
||||
return;
|
||||
}
|
||||
file.previewUrl = "";
|
||||
if (index === 0) {
|
||||
row.invoicePreviewUrl = "";
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
});
|
||||
if (tasks.length > 0) {
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
const protocolPreviewTasks: Array<Promise<void>> = [];
|
||||
Object.keys(laborByExpert.value).forEach((id) => {
|
||||
const expertId = Number(id || 0);
|
||||
const row = laborByExpert.value[expertId];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const key = String(row.protocolOssKey || "").trim();
|
||||
const nameOrKey = String(row.protocolFileName || key || "").trim();
|
||||
if (!key || !isImageFile(nameOrKey)) {
|
||||
row.protocolPreviewUrl = "";
|
||||
return;
|
||||
}
|
||||
protocolPreviewTasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetchFilePresignDownload({ objectKey: key });
|
||||
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
|
||||
return;
|
||||
}
|
||||
row.protocolPreviewUrl = resp?.data?.signedUrl || "";
|
||||
} catch (_e) {
|
||||
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
|
||||
return;
|
||||
}
|
||||
row.protocolPreviewUrl = "";
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
if (protocolPreviewTasks.length > 0) {
|
||||
await Promise.allSettled(protocolPreviewTasks);
|
||||
}
|
||||
})();
|
||||
// 移除预加载逻辑,改为点击预览时按需加载 (handleViewOss 中已实现)
|
||||
}
|
||||
if (moduleCode === "EXPERT_PROFILE") {
|
||||
const profileKey = String(expertProfileForm.value.ossKey || "").trim();
|
||||
@@ -6447,6 +6332,16 @@ const resolveSelectedMeetingCateringRatio = () => {
|
||||
return Math.max(0, Number(currentRow?.cateringRatio || 0));
|
||||
};
|
||||
|
||||
const resolveSelectedMeetingLaborRatio = () => {
|
||||
const meetingId = Number(selectedMeetingId.value || 0);
|
||||
const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
|
||||
if (meetingId > 0 && currentDetailId === meetingId) {
|
||||
return Math.max(0, Number(currentMeetingDetail.value?.laborRatio || 0));
|
||||
}
|
||||
const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
|
||||
return Math.max(0, Number(currentRow?.laborRatio || 0));
|
||||
};
|
||||
|
||||
const validateMeetingInvoiceRequired = () => {
|
||||
if (selectedModuleCode.value !== "MEETING_INVOICE") {
|
||||
return true;
|
||||
@@ -6552,6 +6447,22 @@ const calcMeetingInvoiceUsageFromContentJson = (contentJson: string) => {
|
||||
return calcMeetingInvoiceUsageFromForm(normalized);
|
||||
};
|
||||
|
||||
const calcMeetingCateringUsageFromForm = (formState: MeetingInvoiceFormState) => {
|
||||
const sectionState = formState.sections?.["CATERING_DETAIL"];
|
||||
if (!sectionState) return 0;
|
||||
const amountYuan = Number(sectionState.totalAmountYuan);
|
||||
if (!Number.isFinite(amountYuan) || amountYuan < 0) {
|
||||
return 0;
|
||||
}
|
||||
return toCent(amountYuan);
|
||||
};
|
||||
|
||||
const calcMeetingCateringUsageFromContentJson = (contentJson: string) => {
|
||||
const parsed = safeParse(contentJson || "{}");
|
||||
const normalized = normalizeMeetingInvoiceForm(parsed);
|
||||
return calcMeetingCateringUsageFromForm(normalized);
|
||||
};
|
||||
|
||||
const materialBudgetSummary = computed(() => {
|
||||
const base = materialBudgetBase.value;
|
||||
if (!base.ready) {
|
||||
@@ -6563,12 +6474,15 @@ const materialBudgetSummary = computed(() => {
|
||||
usedTotalCent: 0,
|
||||
remainCent: 0,
|
||||
overCent: 0,
|
||||
remainLaborCent: 0,
|
||||
remainCateringCent: 0,
|
||||
ready: false,
|
||||
};
|
||||
}
|
||||
let laborTotalCent = base.laborTotalCent;
|
||||
let invoiceTotalCent = base.invoiceTotalCent;
|
||||
let meetingInvoiceTotalCent = base.meetingInvoiceTotalCent;
|
||||
let cateringTotalCent = base.cateringTotalCent || 0;
|
||||
if (selectedModuleCode.value === "EXPERT_LIST") {
|
||||
const currentExpertUsage = calcExpertUsageFromCurrentForm();
|
||||
laborTotalCent = currentExpertUsage.laborTotalCent;
|
||||
@@ -6576,10 +6490,20 @@ const materialBudgetSummary = computed(() => {
|
||||
}
|
||||
if (selectedModuleCode.value === "MEETING_INVOICE") {
|
||||
meetingInvoiceTotalCent = calcMeetingInvoiceUsageFromForm(meetingInvoiceForm.value);
|
||||
cateringTotalCent = calcMeetingCateringUsageFromForm(meetingInvoiceForm.value);
|
||||
}
|
||||
const usedTotalCent = laborTotalCent + invoiceTotalCent + meetingInvoiceTotalCent;
|
||||
const remainCent = Math.max(0, base.budgetCent - usedTotalCent);
|
||||
const overCent = Math.max(0, usedTotalCent - base.budgetCent);
|
||||
|
||||
const laborRatio = resolveSelectedMeetingLaborRatio();
|
||||
const maxLaborCent = Math.floor(base.budgetCent * laborRatio);
|
||||
const remainLaborCent = maxLaborCent - laborTotalCent;
|
||||
|
||||
const cateringRatio = resolveSelectedMeetingCateringRatio();
|
||||
const maxCateringCent = Math.floor(base.budgetCent * cateringRatio);
|
||||
const remainCateringCent = maxCateringCent - cateringTotalCent;
|
||||
|
||||
return {
|
||||
budgetCent: base.budgetCent,
|
||||
laborTotalCent,
|
||||
@@ -6588,6 +6512,8 @@ const materialBudgetSummary = computed(() => {
|
||||
usedTotalCent,
|
||||
remainCent,
|
||||
overCent,
|
||||
remainLaborCent,
|
||||
remainCateringCent,
|
||||
ready: true,
|
||||
};
|
||||
});
|
||||
@@ -6633,6 +6559,7 @@ const refreshMaterialBudgetBase = async (meetingIdInput?: number, requestId?: nu
|
||||
laborTotalCent: 0,
|
||||
invoiceTotalCent: 0,
|
||||
meetingInvoiceTotalCent: 0,
|
||||
cateringTotalCent: 0,
|
||||
ready: false,
|
||||
};
|
||||
materialBudgetLoading.value = false;
|
||||
@@ -6650,11 +6577,13 @@ const refreshMaterialBudgetBase = async (meetingIdInput?: number, requestId?: nu
|
||||
}
|
||||
const expertUsage = calcExpertUsageFromContentJson(expertContentJson);
|
||||
const meetingInvoiceTotalCent = calcMeetingInvoiceUsageFromContentJson(meetingInvoiceContentJson);
|
||||
const cateringTotalCent = calcMeetingCateringUsageFromContentJson(meetingInvoiceContentJson);
|
||||
materialBudgetBase.value = {
|
||||
budgetCent,
|
||||
laborTotalCent: expertUsage.laborTotalCent,
|
||||
invoiceTotalCent: expertUsage.invoiceTotalCent,
|
||||
meetingInvoiceTotalCent,
|
||||
cateringTotalCent,
|
||||
ready: true,
|
||||
};
|
||||
} finally {
|
||||
@@ -6670,11 +6599,19 @@ const validateMeetingBudgetLimit = async () => {
|
||||
return true;
|
||||
}
|
||||
const summary = materialBudgetSummary.value;
|
||||
if (summary.overCent <= 0) {
|
||||
return true;
|
||||
if (summary.overCent > 0) {
|
||||
ElMessage.warning(`预算校验不通过:当前会议预算${toYuan(summary.budgetCent)}元,已用${toYuan(summary.usedTotalCent)}元,超预算${toYuan(summary.overCent)}元`);
|
||||
return false;
|
||||
}
|
||||
ElMessage.warning(`预算校验不通过:当前会议预算${toYuan(summary.budgetCent)}元,已用${toYuan(summary.usedTotalCent)}元,超预算${toYuan(summary.overCent)}元`);
|
||||
return false;
|
||||
if (summary.remainLaborCent < 0) {
|
||||
ElMessage.warning(`预算校验不通过:劳务费已超出占比允许的最大额度,超额${toYuan(-summary.remainLaborCent)}元`);
|
||||
return false;
|
||||
}
|
||||
if (summary.remainCateringCent < 0) {
|
||||
ElMessage.warning(`预算校验不通过:餐费已超出占比允许的最大额度,超额${toYuan(-summary.remainCateringCent)}元`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSaveMaterial = async () => {
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
<el-option label="审核退回" value="AUDIT_RETURNED" />
|
||||
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
|
||||
<el-option label="用户创建" value="USER_CREATED" />
|
||||
<el-option label="密码重置" value="USER_PASSWORD_RESET" />
|
||||
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -153,6 +154,7 @@
|
||||
<el-option label="审核退回" value="AUDIT_RETURNED" />
|
||||
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
|
||||
<el-option label="用户创建" value="USER_CREATED" />
|
||||
<el-option label="密码重置" value="USER_PASSWORD_RESET" />
|
||||
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -217,6 +219,7 @@
|
||||
<el-option label="审核退回" value="AUDIT_RETURNED" />
|
||||
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
|
||||
<el-option label="用户创建" value="USER_CREATED" />
|
||||
<el-option label="密码重置" value="USER_PASSWORD_RESET" />
|
||||
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -350,9 +353,10 @@ const dispatchFormRules = ref<FormRules>({
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
const normalizeUpper = (value: unknown) => String(value || "").trim().toUpperCase();
|
||||
const isUserCreatedEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_CREATED";
|
||||
const isUserPasswordResetEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_PASSWORD_RESET";
|
||||
const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unknown) => {
|
||||
const normalizedEventCode = normalizeUpper(eventCode);
|
||||
if (isUserCreatedEvent(eventCode)) {
|
||||
if (isUserCreatedEvent(eventCode) || isUserPasswordResetEvent(eventCode)) {
|
||||
return "TARGET_USER";
|
||||
}
|
||||
if (normalizedEventCode === "AUDIT_TASK_ASSIGNED") {
|
||||
@@ -370,7 +374,7 @@ const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unk
|
||||
: String(currentReceiverType || "SUBMITTER");
|
||||
};
|
||||
const resolveBizTypeByEvent = (eventCode: unknown, currentBizType: unknown) => {
|
||||
if (isUserCreatedEvent(eventCode)) {
|
||||
if (isUserCreatedEvent(eventCode) || isUserPasswordResetEvent(eventCode)) {
|
||||
return "USER";
|
||||
}
|
||||
return String(currentBizType || "").trim().toUpperCase() === "USER"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">SMTP / 邮件发送配置</div>
|
||||
<div class="card-subtitle">配置完成后,通知邮件和租户管理员账号邮件都会优先读取这里的网关参数。</div>
|
||||
<div class="card-subtitle">配置完成后,通知邮件会优先读取这里的网关参数。</div>
|
||||
</div>
|
||||
<el-tag :type="emailForm.status === 'ENABLED' ? 'success' : 'info'">{{ emailForm.status === "ENABLED" ? "已启用" : "未启用" }}</el-tag>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<PageContainer title="项目管理">
|
||||
<QueryToolbar>
|
||||
<el-form :inline="true" @submit.prevent class="project-page-toolbar">
|
||||
@@ -34,16 +34,16 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" />
|
||||
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" />
|
||||
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" width="100"/>
|
||||
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" width="115"/>
|
||||
<!-- <el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" /> -->
|
||||
<el-table-column prop="meetingTotal" label="会议总期数" width="100"/>
|
||||
<el-table-column prop="meetingTotal" label="会议总期数" width="90"/>
|
||||
<el-table-column label="项目周期" width="200">
|
||||
<template #default="{ row }">{{ row.startDate || "-" }} ~ {{ row.endDate || "-" }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" :formatter="statusFormatter" width="100"/>
|
||||
<el-table-column label="操作" width="370">
|
||||
<el-table-column label="操作" width="370" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button>
|
||||
<el-button size="small" @click="openDetailDrawer(row)">详情</el-button>
|
||||
@@ -120,13 +120,13 @@
|
||||
<el-table-column prop="afterValue" label="变更后" />
|
||||
<el-table-column prop="changeReason" label="变更原因" width="150" />
|
||||
<el-table-column prop="operatorUserName" label="操作人" width="140" />
|
||||
<el-table-column prop="handoverAt" label="交接时间" width="180" />
|
||||
<!-- <el-table-column prop="handoverAt" label="交接时间" width="180" /> -->
|
||||
<el-table-column prop="createdAt" label="记录时间" width="180" />
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bindingVisible" title="绑定项目人员" :width="DIALOG_WIDTH.lg">
|
||||
<el-form label-position="left" :label-width="LABEL_WIDTH.lg">
|
||||
<el-form label-position="left" :label-width="LABEL_WIDTH.lg" style="margin-top: 10px;">
|
||||
<el-form-item v-if="!isProjectExecutorRole" label="项目负责人">
|
||||
<div class="binding-select-field">
|
||||
<div v-if="ownerReadonlyBoundUsers.length" class="binding-readonly-block">
|
||||
|
||||
@@ -16,16 +16,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板状态">
|
||||
<el-select v-model="templateQuery.status" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option v-for="item in templateStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="水印下载">
|
||||
<el-select v-model="templateQuery.watermarkEnabled" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option v-for="item in watermarkFilterOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button v-if="canRead" type="primary" @click="handleTemplateSearch">查询</el-button>
|
||||
</el-form-item>
|
||||
@@ -47,23 +38,19 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bizScene" label="业务场景" width="140" :formatter="bizSceneFormatter" />
|
||||
<el-table-column prop="currentVersionNo" label="当前版本" width="100" />
|
||||
<!-- <el-table-column prop="currentVersionNo" label="当前版本" width="100" /> -->
|
||||
<el-table-column prop="status" label="状态" width="110" :formatter="statusFormatter" />
|
||||
<el-table-column label="生效时间" min-width="220">
|
||||
<template #default="{ row }">
|
||||
{{ formatEffectiveTime(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="水印" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.watermarkEnabled ? "开启" : "关闭" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" />
|
||||
|
||||
<!-- <el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" /> -->
|
||||
<el-table-column prop="updatedAt" label="最近更新" width="180" />
|
||||
<el-table-column label="操作" min-width="280" fixed="right">
|
||||
<el-table-column label="操作" min-width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" v-if="canRead" @click="openDetailDrawer(row)">查看详情</el-button>
|
||||
<el-button size="small" v-if="canDetailRead" @click="openDetailDrawer(row)">查看详情</el-button>
|
||||
<el-button size="small" v-if="canRead" @click="focusLogs(row)">
|
||||
{{ canReadAllLogs ? "查看下载日志" : "查看我的下载日志" }}
|
||||
</el-button>
|
||||
@@ -76,15 +63,7 @@
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
v-if="canDownload"
|
||||
:disabled="isWatermarkDownloadBlocked(row)"
|
||||
@click="handleDownloadWatermark(row)"
|
||||
>
|
||||
水印下载
|
||||
</el-button>
|
||||
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -123,12 +102,7 @@
|
||||
<el-form-item label="版本号">
|
||||
<el-input-number v-model="logQuery.versionNo" :min="1" class="w-input-sm" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下载方式">
|
||||
<el-select v-model="logQuery.downloadType" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option label="普通下载" value="NORMAL" />
|
||||
<el-option label="水印下载" value="WATERMARK" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="canReadAllLogs" label="IP">
|
||||
<el-input v-model="logQuery.ip" clearable placeholder="请输入 IP" class="w-input-md" />
|
||||
</el-form-item>
|
||||
@@ -157,11 +131,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="versionNo" label="版本号" width="90" />
|
||||
<el-table-column label="下载方式" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getDownloadTypeLabel(row.downloadType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.userName || row.userPhone">
|
||||
@@ -170,7 +140,7 @@
|
||||
<span v-else>{{ `用户#${row.userId}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="watermarkText" label="水印文案" min-width="160" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="objectKey" label="ObjectKey" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="140" />
|
||||
<el-table-column prop="downloadedAt" label="下载时间" width="180" />
|
||||
@@ -201,7 +171,7 @@
|
||||
<el-descriptions-item label="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本">V{{ detailRow.currentVersionNo || 1 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效时间">{{ formatEffectiveTime(detailRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="水印下载">{{ detailRow.watermarkEnabled ? "开启" : "关闭" }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="下载限流/小时">{{ detailRow.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下载状态">
|
||||
@@ -216,14 +186,6 @@
|
||||
<el-button v-if="canDownload" type="primary" :disabled="isDownloadBlocked(detailRow)" @click="handleDownload(detailRow)">
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDownload"
|
||||
type="primary"
|
||||
:disabled="isWatermarkDownloadBlocked(detailRow)"
|
||||
@click="handleDownloadWatermark(detailRow)"
|
||||
>
|
||||
水印下载
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions v-if="currentVersionDetail" class="mt-md" :column="2" border>
|
||||
@@ -261,11 +223,7 @@
|
||||
<el-tab-pane :label="canReadAllLogs ? '最近下载' : '我的最近下载'">
|
||||
<el-table :data="detailDownloadRows" empty-text="暂无下载记录">
|
||||
<el-table-column prop="downloadedAt" label="下载时间" width="180" />
|
||||
<el-table-column label="下载方式" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getDownloadTypeLabel(row.downloadType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.userName || row.userPhone">
|
||||
@@ -274,7 +232,7 @@
|
||||
<span v-else>{{ `用户#${row.userId}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="watermarkText" label="水印文案" min-width="160" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="ip" label="IP" width="140" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
@@ -291,11 +249,11 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import {
|
||||
downloadTemplate,
|
||||
downloadTemplateWatermark,
|
||||
fetchTemplateDownloadLogs,
|
||||
|
||||
fetchTemplateViewList,
|
||||
fetchTemplateTypeOptions,
|
||||
fetchTemplateDownloadLogs,
|
||||
fetchTemplateVersions,
|
||||
fetchTemplates,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { DRAWER_SIZE } from "../../constants/ui";
|
||||
@@ -323,6 +281,7 @@ const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.template.read));
|
||||
const canDetailRead = computed(() => authStore.hasPermission(PERMS.template.detailRead));
|
||||
const canDownload = computed(() => authStore.hasPermission(PERMS.template.download));
|
||||
const canReadAllLogs = computed(() => authStore.hasPermission(PERMS.template.downloadLogReadAll));
|
||||
|
||||
@@ -345,12 +304,7 @@ const detailDownloadRows = ref<any[]>([]);
|
||||
|
||||
const templateTypeOptions = ref<Array<{ typeCode: TemplateTypeCode; typeName: string; status: "ENABLED" | "DISABLED" }>>([]);
|
||||
|
||||
const templateStatusOptions = [
|
||||
{ label: "草稿", value: "DRAFT" },
|
||||
{ label: "已发布", value: "PUBLISHED" },
|
||||
{ label: "已停用", value: "DISABLED" },
|
||||
{ label: "已归档", value: "ARCHIVED" },
|
||||
];
|
||||
|
||||
|
||||
const scopeTypeOptions = [
|
||||
{ label: "全部", value: "ALL" },
|
||||
@@ -358,16 +312,9 @@ const scopeTypeOptions = [
|
||||
{ label: "会议级", value: "MEETING" },
|
||||
];
|
||||
|
||||
const watermarkFilterOptions = [
|
||||
{ label: "开启", value: "true" },
|
||||
{ label: "关闭", value: "false" },
|
||||
];
|
||||
|
||||
const templateQuery = ref({
|
||||
templateName: "",
|
||||
templateType: "" as TemplateTypeCode | "",
|
||||
status: "",
|
||||
watermarkEnabled: "" as "" | "true" | "false",
|
||||
});
|
||||
|
||||
const logQuery = ref({
|
||||
@@ -376,7 +323,6 @@ const logQuery = ref({
|
||||
userId: undefined as number | undefined,
|
||||
userKeyword: "",
|
||||
versionNo: undefined as number | undefined,
|
||||
downloadType: "" as "" | "NORMAL" | "WATERMARK",
|
||||
ip: "",
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
@@ -409,7 +355,7 @@ const getTemplateTypeLabel = (code: string) =>
|
||||
const getScopeTypeLabel = (code: ScopeTypeCode | string) =>
|
||||
scopeTypeOptions.find((item) => item.value === code)?.label || code || "-";
|
||||
|
||||
const getDownloadTypeLabel = (value?: unknown) => (String(value ?? "").trim() === "WATERMARK" ? "水印下载" : "普通下载");
|
||||
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => getStatusLabel(value);
|
||||
const bizSceneFormatter = (_row: any, _column: any, value: unknown) => getBizSceneLabel(value);
|
||||
@@ -425,7 +371,6 @@ const formatEffectiveTime = (row: any) => {
|
||||
|
||||
const buildTemplateName = (row: any) => row?.templateName || `模板#${row?.id ?? "-"}`;
|
||||
const isDownloadBlocked = (row?: any) => ["DISABLED", "ARCHIVED"].includes(String(row?.status || "").trim().toUpperCase());
|
||||
const isWatermarkDownloadBlocked = (row?: any) => isDownloadBlocked(row) || !row?.watermarkEnabled;
|
||||
|
||||
const confirmAction = async (message: string, title: string) => {
|
||||
try {
|
||||
@@ -443,8 +388,7 @@ const confirmAction = async (message: string, title: string) => {
|
||||
const buildTemplateParams = () => ({
|
||||
templateName: templateQuery.value.templateName || undefined,
|
||||
templateType: templateQuery.value.templateType || undefined,
|
||||
status: templateQuery.value.status || undefined,
|
||||
watermarkEnabled: templateQuery.value.watermarkEnabled ? templateQuery.value.watermarkEnabled === "true" : undefined,
|
||||
status: "PUBLISHED",
|
||||
pageNo: templatePageNo.value,
|
||||
pageSize: templatePageSize.value,
|
||||
});
|
||||
@@ -455,7 +399,6 @@ const buildLogParams = () => ({
|
||||
userId: canReadAllLogs.value ? logQuery.value.userId : undefined,
|
||||
userKeyword: canReadAllLogs.value ? logQuery.value.userKeyword || undefined : undefined,
|
||||
versionNo: logQuery.value.versionNo,
|
||||
downloadType: logQuery.value.downloadType || undefined,
|
||||
ip: canReadAllLogs.value ? logQuery.value.ip || undefined : undefined,
|
||||
downloadedFrom: logQuery.value.dateRange?.[0] || undefined,
|
||||
downloadedTo: logQuery.value.dateRange?.[1] || undefined,
|
||||
@@ -490,7 +433,7 @@ const loadTemplates = async () => {
|
||||
templateTotalCount.value = 0;
|
||||
return;
|
||||
}
|
||||
const resp = await fetchTemplates(buildTemplateParams());
|
||||
const resp = await fetchTemplateViewList(buildTemplateParams());
|
||||
templateRows.value = resp?.data?.list || [];
|
||||
templateTotalCount.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
@@ -544,8 +487,6 @@ const resetTemplateQuery = async () => {
|
||||
templateQuery.value = {
|
||||
templateName: "",
|
||||
templateType: "",
|
||||
status: "",
|
||||
watermarkEnabled: "",
|
||||
};
|
||||
templatePageNo.value = 1;
|
||||
templatePageSize.value = 20;
|
||||
@@ -559,7 +500,6 @@ const resetLogQuery = async () => {
|
||||
userId: undefined,
|
||||
userKeyword: "",
|
||||
versionNo: undefined,
|
||||
downloadType: "",
|
||||
ip: "",
|
||||
dateRange: [],
|
||||
};
|
||||
@@ -594,28 +534,7 @@ const handleDownload = async (row: any) => {
|
||||
await Promise.all([loadLogs(), refreshDetailData()]);
|
||||
};
|
||||
|
||||
const handleDownloadWatermark = async (row: any) => {
|
||||
if (isWatermarkDownloadBlocked(row)) {
|
||||
ElMessage.warning(isDownloadBlocked(row) ? "该模板已停用或归档,当前不可下载" : "该模板未开启水印下载");
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmAction(
|
||||
`确认对「${buildTemplateName(row)}」执行水印下载?系统会记录本次下载日志。`,
|
||||
"水印下载",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const resp = await downloadTemplateWatermark(row.id, { watermarkText: "仅限内部使用" });
|
||||
const url = resp?.data?.signedUrl;
|
||||
if (!url) {
|
||||
ElMessage.error("获取水印下载链接失败");
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank");
|
||||
ElMessage.success("已生成水印下载链接并记录下载日志");
|
||||
await Promise.all([loadLogs(), refreshDetailData()]);
|
||||
};
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
initQueryFromRoute();
|
||||
|
||||
@@ -39,11 +39,7 @@
|
||||
<el-option v-for="item in effectiveStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="水印下载">
|
||||
<el-select v-model="listQuery.watermarkEnabled" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option v-for="item in watermarkFilterOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button v-if="canRead" type="primary" @click="handleSearch">查询</el-button>
|
||||
</el-form-item>
|
||||
@@ -122,9 +118,6 @@
|
||||
class="w-input-md"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="开启水印下载">
|
||||
<el-switch v-model="form.watermarkEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下载限流/小时">
|
||||
<el-input-number v-model="form.downloadRateLimitPerHour" :min="1" :max="9999" />
|
||||
</el-form-item>
|
||||
@@ -194,16 +187,20 @@
|
||||
{{ formatEffectiveTime(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="水印" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.watermarkEnabled ? "开启" : "关闭" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" />
|
||||
<el-table-column prop="updatedAt" label="最近更新" width="180" />
|
||||
<el-table-column label="操作" min-width="420" fixed="right">
|
||||
<el-table-column label="操作" min-width="350" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" v-if="canRead" @click="openDetailDrawer(row)">版本管理</el-button>
|
||||
<el-button size="small" v-if="canDetailRead" @click="openDetailDrawer(row)">版本管理</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
v-if="canUpdate && row.status !== 'ARCHIVED'"
|
||||
@click="openEditDrawer(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
@@ -235,6 +232,68 @@
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="editVisible" title="编辑模板" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="editFormRef" :model="editForm" :rules="formRules" label-width="120px" class="pr-md">
|
||||
<el-form-item label="模板名称" prop="templateName">
|
||||
<el-input v-model="editForm.templateName" placeholder="如:XX大会报名签到码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板类型" prop="templateType">
|
||||
<el-select v-model="editForm.templateType" class="w-input-md">
|
||||
<el-option
|
||||
v-for="item in templateTypeOptions.filter(i => i.status === 'ENABLED' || i.typeCode === editForm.templateType)"
|
||||
:key="item.typeCode"
|
||||
:label="item.typeName"
|
||||
:value="item.typeCode"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="适用范围" prop="scopeType">
|
||||
<el-select v-model="editForm.scopeType" class="w-input-md">
|
||||
<el-option v-for="item in scopeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.scopeType === 'PROJECT'" label="关联项目ID" prop="projectId">
|
||||
<el-input-number v-model="editForm.projectId" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.scopeType === 'MEETING'" label="关联会议ID" prop="meetingId">
|
||||
<el-input-number v-model="editForm.meetingId" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="业务场景" prop="bizScene">
|
||||
<el-select v-model="editForm.bizScene" class="w-input-md">
|
||||
<el-option
|
||||
v-for="item in flowSceneOptions"
|
||||
:key="item.sceneCode"
|
||||
:label="item.sceneName"
|
||||
:value="item.sceneCode"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效开始时间">
|
||||
<el-date-picker
|
||||
v-model="editForm.effectiveFrom"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-input-md"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效结束时间">
|
||||
<el-date-picker
|
||||
v-model="editForm.effectiveTo"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-input-md"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="下载限流/小时">
|
||||
<el-input-number v-model="editForm.downloadRateLimitPerHour" :min="1" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button v-if="canUpdate" type="primary" @click="handleEdit">保存修改</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="模板管理详情" :size="DRAWER_SIZE.lg" destroy-on-close>
|
||||
<template v-if="detailRow">
|
||||
<el-descriptions :column="2" border>
|
||||
@@ -245,7 +304,7 @@
|
||||
<el-descriptions-item label="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本">V{{ detailRow.currentVersionNo || 1 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效时间">{{ formatEffectiveTime(detailRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="水印下载">{{ detailRow.watermarkEnabled ? "开启" : "关闭" }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="下载限流/小时">{{ detailRow.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@@ -458,6 +517,8 @@ import {
|
||||
createTemplate,
|
||||
disableTemplate,
|
||||
disableTemplateTypeOption,
|
||||
downloadTemplate,
|
||||
|
||||
enableTemplateTypeOption,
|
||||
fetchPublishedTemplateOptions,
|
||||
fetchTemplateFlowLinks,
|
||||
@@ -469,6 +530,7 @@ import {
|
||||
fetchTemplates,
|
||||
publishTemplate,
|
||||
rollbackTemplate,
|
||||
updateTemplate,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
@@ -495,7 +557,9 @@ const BIZ_SCENE_LABELS: Record<string, string> = {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.template.read));
|
||||
const canDetailRead = computed(() => authStore.hasPermission(PERMS.template.detailRead));
|
||||
const canCreate = computed(() => authStore.hasPermission(PERMS.template.create));
|
||||
const canUpdate = computed(() => authStore.hasPermission(PERMS.template.update));
|
||||
const canPublish = computed(() => authStore.hasPermission(PERMS.template.publish));
|
||||
const canDisable = computed(() => authStore.hasPermission(PERMS.template.disable));
|
||||
const canArchive = computed(() => authStore.hasPermission(PERMS.template.archive));
|
||||
@@ -508,6 +572,7 @@ const pageSize = ref(20);
|
||||
const totalCount = ref(0);
|
||||
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const flowBindingVisible = ref(false);
|
||||
const rollbackDialogVisible = ref(false);
|
||||
@@ -549,10 +614,7 @@ const effectiveStatusOptions = [
|
||||
{ label: "已过期", value: "EXPIRED" },
|
||||
];
|
||||
|
||||
const watermarkFilterOptions = [
|
||||
{ label: "开启", value: "true" },
|
||||
{ label: "关闭", value: "false" },
|
||||
];
|
||||
|
||||
|
||||
const listQuery = ref({
|
||||
templateName: "",
|
||||
@@ -561,7 +623,20 @@ const listQuery = ref({
|
||||
scopeType: "" as ScopeTypeCode | "",
|
||||
bizScene: "" as BizSceneCode | "",
|
||||
effectiveStatus: "",
|
||||
watermarkEnabled: "" as "" | "true" | "false",
|
||||
});
|
||||
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const editForm = ref({
|
||||
id: 0,
|
||||
templateName: "",
|
||||
templateType: "AGENDA" as TemplateTypeCode,
|
||||
scopeType: "ALL" as ScopeTypeCode,
|
||||
projectId: undefined as number | undefined,
|
||||
meetingId: undefined as number | undefined,
|
||||
bizScene: "MEETING_RECOMMEND" as BizSceneCode,
|
||||
effectiveFrom: "",
|
||||
effectiveTo: "",
|
||||
downloadRateLimitPerHour: 100,
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
@@ -575,7 +650,6 @@ const form = ref({
|
||||
changeLog: "",
|
||||
effectiveFrom: "",
|
||||
effectiveTo: "",
|
||||
watermarkEnabled: false,
|
||||
downloadRateLimitPerHour: 100,
|
||||
});
|
||||
|
||||
@@ -666,7 +740,6 @@ const buildListParams = () => ({
|
||||
scopeType: listQuery.value.scopeType || undefined,
|
||||
bizScene: listQuery.value.bizScene || undefined,
|
||||
effectiveStatus: listQuery.value.effectiveStatus || undefined,
|
||||
watermarkEnabled: listQuery.value.watermarkEnabled ? listQuery.value.watermarkEnabled === "true" : undefined,
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
@@ -717,7 +790,6 @@ const resetCreateForm = () => {
|
||||
changeLog: "",
|
||||
effectiveFrom: "",
|
||||
effectiveTo: "",
|
||||
watermarkEnabled: false,
|
||||
downloadRateLimitPerHour: 100,
|
||||
};
|
||||
};
|
||||
@@ -728,6 +800,23 @@ const openCreateDrawer = () => {
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditDrawer = (row: any) => {
|
||||
selectedTemplateId.value = row.id;
|
||||
editForm.value = {
|
||||
id: row.id,
|
||||
templateName: row.templateName || "",
|
||||
templateType: (row.templateType || "AGENDA") as TemplateTypeCode,
|
||||
scopeType: (row.scopeType || "ALL") as ScopeTypeCode,
|
||||
projectId: row.projectId || undefined,
|
||||
meetingId: row.meetingId || undefined,
|
||||
bizScene: (row.bizScene || "MEETING_RECOMMEND") as BizSceneCode,
|
||||
effectiveFrom: row.effectiveFrom || "",
|
||||
effectiveTo: row.effectiveTo || "",
|
||||
downloadRateLimitPerHour: Number(row.downloadRateLimitPerHour || 100),
|
||||
};
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const openFlowBindingDrawer = async () => {
|
||||
if (!canRead.value) {
|
||||
return;
|
||||
@@ -884,7 +973,6 @@ const handleCreate = async () => {
|
||||
changeLog: form.value.changeLog,
|
||||
effectiveFrom: form.value.effectiveFrom || undefined,
|
||||
effectiveTo: form.value.effectiveTo || undefined,
|
||||
watermarkEnabled: form.value.watermarkEnabled,
|
||||
downloadRateLimitPerHour: form.value.downloadRateLimitPerHour,
|
||||
});
|
||||
ElMessage.success("模板创建成功");
|
||||
@@ -892,6 +980,45 @@ const handleCreate = async () => {
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editFormRef.value) {
|
||||
return;
|
||||
}
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
if (editForm.value.scopeType === "PROJECT" && !editForm.value.projectId) {
|
||||
ElMessage.warning("项目级模板需要填写项目ID");
|
||||
return;
|
||||
}
|
||||
if (editForm.value.scopeType === "MEETING" && !editForm.value.meetingId) {
|
||||
ElMessage.warning("会议级模板需要填写会议ID");
|
||||
return;
|
||||
}
|
||||
if (editForm.value.effectiveFrom && editForm.value.effectiveTo && editForm.value.effectiveFrom > editForm.value.effectiveTo) {
|
||||
ElMessage.warning("生效开始时间不能晚于生效结束时间");
|
||||
return;
|
||||
}
|
||||
await updateTemplate(editForm.value.id, {
|
||||
templateName: editForm.value.templateName,
|
||||
templateType: editForm.value.templateType,
|
||||
scopeType: editForm.value.scopeType,
|
||||
projectId: editForm.value.projectId,
|
||||
meetingId: editForm.value.meetingId,
|
||||
bizScene: editForm.value.bizScene,
|
||||
effectiveFrom: editForm.value.effectiveFrom || undefined,
|
||||
effectiveTo: editForm.value.effectiveTo || undefined,
|
||||
downloadRateLimitPerHour: editForm.value.downloadRateLimitPerHour,
|
||||
});
|
||||
ElMessage.success("模板修改成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
if (detailVisible.value && Number(detailRow.value?.id) === Number(editForm.value.id)) {
|
||||
await refreshDetailData();
|
||||
}
|
||||
};
|
||||
|
||||
const enableType = async (typeCode: string) => {
|
||||
await enableTemplateTypeOption(typeCode);
|
||||
ElMessage.success(`模板类型 ${typeCode} 已启用`);
|
||||
@@ -1116,7 +1243,6 @@ const resetListQuery = async () => {
|
||||
scopeType: "",
|
||||
bizScene: "",
|
||||
effectiveStatus: "",
|
||||
watermarkEnabled: "",
|
||||
};
|
||||
pageNo.value = 1;
|
||||
pageSize.value = 20;
|
||||
|
||||
@@ -74,24 +74,17 @@
|
||||
stripe
|
||||
:empty-text="loading ? '加载中...' : '暂无待审批任务'"
|
||||
>
|
||||
<el-table-column prop="id" label="任务ID" width="80" />
|
||||
<el-table-column prop="meetingId" label="关联会议ID" width="100" />
|
||||
<el-table-column prop="moduleCode" label="模块" min-width="120">
|
||||
<el-table-column label="会议信息" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ formatModuleCode(row.moduleCode) }}</el-tag>
|
||||
{{ meetingNameMap[row.meetingId] || `会议#${row.meetingId}` }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nodeStatus" label="状态" width="100">
|
||||
<el-table-column prop="node" label="审批节点" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="row.nodeStatus === 'PENDING' ? 'warning' : 'info'"
|
||||
>
|
||||
{{ row.nodeStatus === 'PENDING' ? '待处理' : row.nodeStatus }}
|
||||
</el-tag>
|
||||
<el-tag size="small">{{ formatAuditNode(row.node) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="到达时间" width="180" />
|
||||
<el-table-column prop="submittedAt" label="到达时间" width="180" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
@@ -111,10 +104,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { fetchAuditTasks, fetchDashboardStats } from "../../api/modules";
|
||||
import { fetchAuditTasks, fetchDashboardStats, fetchMeetings } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhModuleCode } from "../../utils/status";
|
||||
import { toZhAuditNode } from "../../utils/status";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
@@ -127,8 +120,9 @@ const myPendingTasks = ref<any[]>([]);
|
||||
const pendingAuditCount = ref(0);
|
||||
const activeMeetingCount = ref(0);
|
||||
const pendingFinanceCount = ref(0);
|
||||
const meetingNameMap = ref<Record<number, string>>({});
|
||||
|
||||
const formatModuleCode = (code: string) => toZhModuleCode(code);
|
||||
const formatAuditNode = (code: string) => toZhAuditNode(code);
|
||||
|
||||
const goToAudit = (meetingId?: number) => {
|
||||
if (meetingId) {
|
||||
@@ -139,17 +133,19 @@ const goToAudit = (meetingId?: number) => {
|
||||
};
|
||||
|
||||
const loadMyTasks = async () => {
|
||||
if (!authStore.hasPermission(PERMS.audit.read)) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await fetchAuditTasks({ mine: true, pageNo: 1, pageSize: 50 });
|
||||
const resp = await fetchAuditTasks({ mine: true, scope: "PENDING_MINE", pageNo: 1, pageSize: 50 });
|
||||
const list = resp?.data?.list || [];
|
||||
// 过滤出PENDING的任务以供展示
|
||||
const pendings = list.filter((x: any) => x.nodeStatus === "PENDING" || x.nodeStatus === "CLAIMED");
|
||||
const pendings = list.filter((x: any) => x.status === "PENDING" || x.status === "CLAIMED");
|
||||
myPendingTasks.value = pendings.slice(0, 10); // 工作台只展示Top 10
|
||||
pendingAuditCount.value = pendings.length;
|
||||
|
||||
// 如果有待办任务,获取会议名称
|
||||
if (pendings.length > 0) {
|
||||
await loadNameMaps();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
@@ -172,6 +168,25 @@ const loadDashboardStats = async () => {
|
||||
// 接口未就绪时静默失败
|
||||
}
|
||||
};
|
||||
|
||||
const loadNameMaps = async () => {
|
||||
try {
|
||||
const meetingResp = await fetchMeetings({ pageNo: 1, pageSize: 200 });
|
||||
const meetings = meetingResp?.data?.list || [];
|
||||
const meetingMap: Record<number, string> = {};
|
||||
meetings.forEach((item: any) => {
|
||||
const id = Number(item?.id || 0);
|
||||
if (id > 0) {
|
||||
const projectName = String(item?.projectName || "").trim();
|
||||
const topic = String(item?.topic || "").trim();
|
||||
meetingMap[id] = projectName && topic ? `${projectName} / ${topic}` : topic || `会议#${id}`;
|
||||
}
|
||||
});
|
||||
meetingNameMap.value = meetingMap;
|
||||
} catch (_e) {
|
||||
meetingNameMap.value = {};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="tenantCode" label="租户编码" width="220" />
|
||||
<el-table-column prop="tenantName" label="租户名称" />
|
||||
<el-table-column prop="tenantCode" label="租户编码" width="200" />
|
||||
<el-table-column prop="tenantName" label="租户名称" width="220"/>
|
||||
<el-table-column label="Logo" width="180">
|
||||
<template #default="{ row }">
|
||||
<img
|
||||
@@ -21,7 +21,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="190" />
|
||||
<el-table-column label="操作" width="420">
|
||||
<el-table-column label="操作" width="380">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
@@ -121,10 +121,10 @@
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="adminDialogVisible" title="设置租户管理员" :width="DIALOG_WIDTH.md">
|
||||
<div class="mb-md text-regular">
|
||||
<div class="mb-md text-regular" style="margin-top: 10px;">
|
||||
当前租户:{{ selectedTenant?.tenantName }}({{ selectedTenant?.tenantCode }})
|
||||
</div>
|
||||
<el-form ref="adminFormRef" :model="adminForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="adminFormRules">
|
||||
<el-form ref="adminFormRef" :model="adminForm" label-position="left" :label-width="LABEL_WIDTH.lg" :rules="adminFormRules">
|
||||
<el-form-item label="管理员姓名" prop="userName">
|
||||
<el-input v-model="adminForm.userName" />
|
||||
</el-form-item>
|
||||
@@ -141,7 +141,7 @@
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="系统会向该邮箱发送一次性首次设密链接,不再发送明文初始密码。"
|
||||
title="创建成功后将直接在页面显示一次性设密链接,请注意保存以便发给管理员。"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -469,12 +469,17 @@ const handleSetAdmin = async () => {
|
||||
roleCode: adminForm.value.roleCode.trim() || undefined,
|
||||
});
|
||||
const action = String(resp?.data?.action || "");
|
||||
ElMessage.success(
|
||||
action === "UPDATED"
|
||||
? "租户管理员更新成功,首次设密链接已发送到邮箱"
|
||||
: "租户管理员创建成功,首次设密链接已发送到邮箱",
|
||||
);
|
||||
const setupLink = String(resp?.data?.setupLink || "");
|
||||
const isUpdate = action === "UPDATED";
|
||||
ElMessage.success(isUpdate ? "租户管理员更新成功" : "租户管理员创建成功");
|
||||
adminDialogVisible.value = false;
|
||||
if (setupLink) {
|
||||
ElMessageBox.alert(
|
||||
`<div style="word-break: break-all;">管理员设密链接:<br/><br/><a href="${setupLink}" target="_blank">${setupLink}</a></div>`,
|
||||
"请保存并发送给管理员",
|
||||
{ dangerouslyUseHTMLString: true, confirmButtonText: "确定" }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitBaseline = async (row: any) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<div class="setup-badge">首次设密</div>
|
||||
<h1>租户管理员密码设置</h1>
|
||||
<h1>账号密码设置</h1>
|
||||
<p>完成一次性密码配置后即可前往登录页登录。</p>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<template v-else>
|
||||
<div class="setup-info">
|
||||
<div class="info-row">
|
||||
<span>管理员</span>
|
||||
<span>用户姓名</span>
|
||||
<strong>{{ verifyData.userName || "-" }}</strong>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
|
||||
@@ -134,10 +134,6 @@
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
<PasswordStrengthBar :password="form.password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
@@ -307,7 +303,6 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import PasswordStrengthBar from "../../components/PasswordStrengthBar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
@@ -384,7 +379,6 @@ const delegationForm = ref({
|
||||
const form = ref({
|
||||
userName: "测试用户",
|
||||
phone: "13800000000",
|
||||
password: "123456",
|
||||
email: "test@example.com",
|
||||
validFrom: "",
|
||||
validTo: "",
|
||||
@@ -399,7 +393,6 @@ const router = useRouter();
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
userName: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
phone: [{ required: true, message: "请输入手机号", trigger: "blur" }],
|
||||
password: [{ required: !isEditMode.value, message: "请输入密码", trigger: "blur" }],
|
||||
}));
|
||||
|
||||
const delegationFormRules = reactive<FormRules>({
|
||||
@@ -429,7 +422,6 @@ const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
userName: "测试用户",
|
||||
phone: "13800000000",
|
||||
password: "123456",
|
||||
email: "test@example.com",
|
||||
validFrom: "",
|
||||
validTo: "",
|
||||
@@ -453,7 +445,6 @@ const openEditDrawer = (row: any) => {
|
||||
form.value = {
|
||||
userName: row.userName,
|
||||
phone: row.phone,
|
||||
password: "",
|
||||
email: row.email,
|
||||
validFrom: row.validFrom,
|
||||
validTo: row.validTo,
|
||||
@@ -541,7 +532,7 @@ const downloadUserTemplate = () => {
|
||||
"validTo",
|
||||
"roleCode",
|
||||
], [
|
||||
["张三", "13800138000", "Abc12345!", "zhangsan@example.com", "2026-04-20 09:00:00", "2099-12-31 23:59:59", "TENANT_ADMIN"],
|
||||
["张三", "13800138000", "zhangsan@example.com", "2026-04-20 09:00:00", "2099-12-31 23:59:59", "TENANT_ADMIN"],
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -571,7 +562,7 @@ const parseUserImportRows = (text: string) => {
|
||||
return [];
|
||||
}
|
||||
const [header, ...dataRows] = rows;
|
||||
const missingHeaders = findMissingHeaders(header, ["userName", "phone", "password"]);
|
||||
const missingHeaders = findMissingHeaders(header, ["userName", "phone"]);
|
||||
if (missingHeaders.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -584,17 +575,14 @@ const parseUserImportRows = (text: string) => {
|
||||
.map((row) => ({
|
||||
userName: row[headerIndex.get("userName") ?? 0] || "",
|
||||
phone: row[headerIndex.get("phone") ?? 1] || "",
|
||||
password: row[headerIndex.get("password") ?? 2] || "",
|
||||
email: row[headerIndex.get("email") ?? 3] || "",
|
||||
validFrom: row[headerIndex.get("validFrom") ?? 4] || "",
|
||||
validTo: row[headerIndex.get("validTo") ?? 5] || "",
|
||||
roleCode: row[headerIndex.get("roleCode") ?? 6] || "",
|
||||
email: row[headerIndex.get("email") ?? 2] || "",
|
||||
validFrom: row[headerIndex.get("validFrom") ?? 3] || "",
|
||||
validTo: row[headerIndex.get("validTo") ?? 4] || "",
|
||||
roleCode: row[headerIndex.get("roleCode") ?? 5] || "",
|
||||
}))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
userName: item.userName.trim(),
|
||||
phone: item.phone.trim(),
|
||||
password: item.password.trim(),
|
||||
email: item.email.trim(),
|
||||
validFrom: item.validFrom.trim(),
|
||||
validTo: item.validTo.trim(),
|
||||
@@ -605,10 +593,10 @@ const parseUserImportRows = (text: string) => {
|
||||
const validateUserImportHeaders = (text: string) => {
|
||||
const rows = parseDelimitedText(text);
|
||||
if (!rows.length) {
|
||||
return { ok: false, missingHeaders: ["userName", "phone", "password"] };
|
||||
return { ok: false, missingHeaders: ["userName", "phone"] };
|
||||
}
|
||||
const [header] = rows;
|
||||
const missingHeaders = findMissingHeaders(header, ["userName", "phone", "password"]);
|
||||
const missingHeaders = findMissingHeaders(header, ["userName", "phone"]);
|
||||
return { ok: missingHeaders.length === 0, missingHeaders };
|
||||
};
|
||||
|
||||
@@ -649,7 +637,6 @@ const downloadUserErrorSnapshot = () => {
|
||||
const rows: string[][] = [[
|
||||
"userName",
|
||||
"phone",
|
||||
"password",
|
||||
"email",
|
||||
"validFrom",
|
||||
"validTo",
|
||||
@@ -665,7 +652,6 @@ const downloadUserErrorSnapshot = () => {
|
||||
rows.push([
|
||||
item.userName || "",
|
||||
item.phone || "",
|
||||
item.password || "",
|
||||
item.email || "",
|
||||
item.validFrom || "",
|
||||
item.validTo || "",
|
||||
@@ -696,9 +682,6 @@ const handleSubmitUser = async () => {
|
||||
validFrom: form.value.validFrom,
|
||||
validTo: form.value.validTo,
|
||||
};
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
await updateUser(editingUserId.value, payload);
|
||||
if (form.value.roleId) {
|
||||
await assignUserRole({ userId: editingUserId.value, roleId: form.value.roleId });
|
||||
@@ -708,7 +691,6 @@ const handleSubmitUser = async () => {
|
||||
const payload = {
|
||||
userName: form.value.userName,
|
||||
phone: form.value.phone,
|
||||
password: form.value.password,
|
||||
email: form.value.email,
|
||||
validFrom: form.value.validFrom,
|
||||
validTo: form.value.validTo,
|
||||
@@ -748,16 +730,13 @@ const handleDeleteUser = async (userId: number, userName: string) => {
|
||||
};
|
||||
|
||||
const handleResetPassword = async (userId: number) => {
|
||||
const dialog = await ElMessageBox.prompt("请输入新密码", "重置密码", {
|
||||
confirmButtonText: "确定",
|
||||
await ElMessageBox.confirm("确认要重置该用户的密码吗?系统将发送设密链接到该用户的邮箱。", "重置密码", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认重置",
|
||||
cancelButtonText: "取消",
|
||||
inputValue: "123456",
|
||||
}).catch(() => null);
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
await resetUserPassword(userId, { newPassword: dialog.value });
|
||||
ElMessage.success("密码已重置");
|
||||
});
|
||||
await resetUserPassword(userId, {});
|
||||
ElMessage.success("已发送密码重置邮件");
|
||||
};
|
||||
|
||||
const handleViewRoleHistory = async (userId: number) => {
|
||||
|
||||
@@ -470,9 +470,9 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
|
||||
<div class="audit-attachment-panel__head">劳务金额</div>
|
||||
<div class="audit-attachment-panel__body audit-amount-panel">
|
||||
<div class="audit-amount-panel__value">
|
||||
{{ formatYuan(currentLaborPreTaxAmountCent) }} / {{ formatYuan(currentLaborAfterTaxAmountCent) }}
|
||||
应发:{{ formatYuan(currentLaborPreTaxAmountCent) }} 元<br/><br/>实发:{{ formatYuan(currentLaborAfterTaxAmountCent) }} 元
|
||||
</div>
|
||||
<div class="audit-amount-panel__unit">元</div>
|
||||
<!-- <div class="audit-amount-panel__unit">元</div> -->
|
||||
<div class="audit-amount-panel__remark">
|
||||
{{ currentLaborRow.remark || "无备注" }}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ defineEmits<{
|
||||
<el-descriptions-item label="会议形式">{{ currentMeetingDetail.meetingForm || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议地点">{{ currentMeetingDetail.location || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议时间">{{ currentMeetingDetail.startTime || "-" }} ~ {{ currentMeetingDetail.endTime || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预算(元)">{{ toYuan(currentMeetingDetail.budgetCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预算(元)">{{ toYuan(currentMeetingDetail.budgetCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="劳务占比">{{ formatPercent(currentMeetingDetail.laborRatio) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="餐费占比">{{ formatPercent(currentMeetingDetail.cateringRatio) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议状态">{{ toZhStatus(currentMeetingDetail.status) }}</el-descriptions-item>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watch, onBeforeUnmount } from "vue";
|
||||
import { ZoomIn, ZoomOut, RefreshLeft, RefreshRight } from '@element-plus/icons-vue'
|
||||
|
||||
const visible = defineModel<boolean>({ required: true });
|
||||
|
||||
@@ -16,6 +17,90 @@ const dialogTitle = computed(() => {
|
||||
if (kind.value === "excel") return "Excel 预览";
|
||||
return "图片预览";
|
||||
});
|
||||
|
||||
// 图片缩放和旋转状态
|
||||
const scale = ref(1);
|
||||
const rotation = ref(0);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
|
||||
// 拖拽相关状态
|
||||
const isDragging = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
|
||||
// 当弹窗打开或图片更换时重置状态
|
||||
watch([visible, () => props.docPreviewDialogImageUrl], ([newVisible]) => {
|
||||
if (newVisible) {
|
||||
scale.value = 1;
|
||||
rotation.value = 0;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const handleZoomIn = () => {
|
||||
scale.value += 0.2;
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (scale.value > 0.2) {
|
||||
scale.value -= 0.2;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateLeft = () => {
|
||||
rotation.value -= 90;
|
||||
};
|
||||
|
||||
const handleRotateRight = () => {
|
||||
rotation.value += 90;
|
||||
};
|
||||
|
||||
// 鼠标滚轮缩放事件处理
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
// 使用较小的步长以保证滚轮缩放的平滑度
|
||||
const zoomStep = 0.1;
|
||||
if (e.deltaY < 0) {
|
||||
// 向上滚动,放大
|
||||
scale.value += zoomStep;
|
||||
} else {
|
||||
// 向下滚动,缩小
|
||||
if (scale.value > 0.2) {
|
||||
scale.value -= zoomStep;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标拖拽事件处理
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = true;
|
||||
startX.value = e.clientX - translateX.value;
|
||||
startY.value = e.clientY - translateY.value;
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
translateX.value = e.clientX - startX.value;
|
||||
translateY.value = e.clientY - startY.value;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,6 +108,7 @@ const dialogTitle = computed(() => {
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="80%"
|
||||
top="5vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
@@ -33,12 +119,28 @@ const dialogTitle = computed(() => {
|
||||
class="doc-preview-pdf-frame"
|
||||
/>
|
||||
<div v-else-if="kind === 'excel'" class="doc-preview-excel-container" v-html="docPreviewExcelHtml"></div>
|
||||
<div v-else-if="docPreviewDialogImageUrl" class="doc-preview-image-container">
|
||||
<img
|
||||
:src="docPreviewDialogImageUrl"
|
||||
alt="preview"
|
||||
class="doc-preview-image"
|
||||
/>
|
||||
<div v-else-if="docPreviewDialogImageUrl" class="doc-preview-image-wrapper">
|
||||
<div class="image-toolbar">
|
||||
<el-button-group>
|
||||
<el-button :icon="ZoomIn" @click="handleZoomIn" title="放大" />
|
||||
<el-button :icon="ZoomOut" @click="handleZoomOut" title="缩小" />
|
||||
<el-button :icon="RefreshLeft" @click="handleRotateLeft" title="向左旋转" />
|
||||
<el-button :icon="RefreshRight" @click="handleRotateRight" title="向右旋转" />
|
||||
</el-button-group>
|
||||
</div>
|
||||
<div class="doc-preview-image-container" @wheel="handleWheel">
|
||||
<img
|
||||
:src="docPreviewDialogImageUrl"
|
||||
alt="preview"
|
||||
class="doc-preview-image"
|
||||
@mousedown="handleMouseDown"
|
||||
:style="{
|
||||
transform: `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.3s ease',
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -46,10 +148,21 @@ const dialogTitle = computed(() => {
|
||||
<style scoped>
|
||||
.doc-preview-pdf-frame {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
height: 80vh;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
.doc-preview-image-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
.image-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.doc-preview-image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -58,15 +171,20 @@ const dialogTitle = computed(() => {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 60vh;
|
||||
max-height: 80vh;
|
||||
}
|
||||
.doc-preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.doc-preview-excel-container {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
height: 80vh;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
|
||||
@@ -257,7 +257,7 @@ const handleSave = async () => {
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="预算(元)">
|
||||
<el-form-item label="预算(元)">
|
||||
<el-input-number v-model="meetingForm.budgetYuan" :min="0.01" :step="0.01" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="劳务占比">
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps<{
|
||||
finishedAt?: string;
|
||||
}>;
|
||||
canCreate: boolean;
|
||||
canEdit?: boolean;
|
||||
canSubmit: boolean;
|
||||
canSubmitMeeting: (row: any) => boolean;
|
||||
canWithdraw: boolean;
|
||||
@@ -90,12 +91,12 @@ const summaryStatusText = (rowId: number, statusFormatter: (row: any, column: an
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="currentAuditNode" label="当前审核节点" width="160" :formatter="auditNodeFormatter" />
|
||||
<el-table-column prop="currentAuditorUserId" label="当前审核节点" width="160" :formatter="auditorNameFormatter" /> -->
|
||||
<el-table-column label="操作" width="320">
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button size="small" @click="$emit('openDetail', row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canCreate"
|
||||
v-if="canEdit"
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="isMeetingAuditApproved(row)"
|
||||
@@ -148,24 +149,6 @@ const summaryStatusText = (rowId: number, statusFormatter: (row: any, column: an
|
||||
<el-dropdown-item v-if="canMaterialExport" divided @click="$emit('generateSummary', row.id)">
|
||||
生成总结
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canMaterialExport" @click="$emit('checkSummaryStatus', row.id)">
|
||||
查看总结状态
|
||||
{{ summaryTaskMap[row.id]?.taskId ? `(${summaryStatusText(row.id, statusFormatter, summaryTaskMap)})` : "" }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canMaterialExport"
|
||||
:disabled="!summaryTaskMap[row.id]?.taskId || summaryTaskMap[row.id]?.status !== 'SUCCESS'"
|
||||
@click="$emit('refreshSummaryToken', row.id)"
|
||||
>
|
||||
刷新总结令牌
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canMaterialExport"
|
||||
:disabled="summaryTaskMap[row.id]?.status !== 'SUCCESS' || !summaryTaskMap[row.id]?.taskId || !summaryTaskMap[row.id]?.token"
|
||||
@click="$emit('downloadSummary', row.id)"
|
||||
>
|
||||
下载总结
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
@@ -677,9 +677,17 @@ watch(
|
||||
<div class="budget-item"><span class="text-secondary">已用额度</span> <span>¥ {{ toYuan(materialBudgetSummary.usedTotalCent) }}</span></div>
|
||||
<div class="budget-divider"></div>
|
||||
<div class="budget-item" :class="materialBudgetSummary.overCent > 0 ? 'text-danger fw-bold' : 'text-success fw-bold'">
|
||||
<span>{{ materialBudgetSummary.overCent > 0 ? '超发预算' : '剩余额度' }}</span>
|
||||
<span>{{ materialBudgetSummary.overCent > 0 ? '超发预算' : '剩余总额度' }}</span>
|
||||
<span>¥ {{ toYuan(materialBudgetSummary.overCent > 0 ? materialBudgetSummary.overCent : materialBudgetSummary.remainCent) }}</span>
|
||||
</div>
|
||||
<div class="budget-item" :class="materialBudgetSummary.remainLaborCent < 0 ? 'text-danger fw-bold' : 'text-success fw-bold'">
|
||||
<span>剩余劳务费额度</span>
|
||||
<span>¥ {{ toYuan(materialBudgetSummary.remainLaborCent) }}</span>
|
||||
</div>
|
||||
<div class="budget-item" :class="materialBudgetSummary.remainCateringCent < 0 ? 'text-danger fw-bold' : 'text-success fw-bold'">
|
||||
<span>剩余餐费额度</span>
|
||||
<span>¥ {{ toYuan(materialBudgetSummary.remainCateringCent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="materialBudgetLoading" class="sidebar-budget-card sidebar-budget-card--loading">
|
||||
<div class="budget-title">预算看板</div>
|
||||
@@ -1657,7 +1665,7 @@ watch(
|
||||
:on-change="(f, l) => handleUploadChangeWithCompression(f, l, buildMeetingInvoiceUploadChangeHandler(section.code, uploadField.key))"
|
||||
:on-preview="handleMeetingInvoiceSectionPicturePreview"
|
||||
:on-remove="buildMeetingInvoicePictureRemoveHandler(section.code, uploadField.key)"
|
||||
accept=".jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf"
|
||||
:accept="uploadField.key === 'settlementFile' ? '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf,.xls,.xlsx' : '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf'"
|
||||
>
|
||||
<template #file="{ file }">
|
||||
<MaterialPictureCardFileItem
|
||||
@@ -2121,6 +2129,8 @@ watch(
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.material-module-card {
|
||||
@@ -2136,6 +2146,10 @@ watch(
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Custom Segmented Control */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import MeetingQueryToolbar from './MeetingQueryToolbar.vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
describe('MeetingQueryToolbar.vue', () => {
|
||||
it('should render form items properly', () => {
|
||||
const wrapper = mount(MeetingQueryToolbar, {
|
||||
global: {
|
||||
plugins: [ElementPlus]
|
||||
},
|
||||
props: {
|
||||
queryForm: {
|
||||
projectName: '',
|
||||
topic: '',
|
||||
meetingStatus: '',
|
||||
auditStatus: ''
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('项目名称');
|
||||
expect(wrapper.text()).toContain('会议主题');
|
||||
expect(wrapper.text()).toContain('会议状态');
|
||||
expect(wrapper.text()).toContain('审核状态');
|
||||
});
|
||||
|
||||
it('should emit load event when search button is clicked', async () => {
|
||||
const wrapper = mount(MeetingQueryToolbar, {
|
||||
global: {
|
||||
plugins: [ElementPlus]
|
||||
},
|
||||
props: {
|
||||
queryForm: {}
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAll('.el-button');
|
||||
const searchBtn = buttons.find(b => b.text().includes('查询'));
|
||||
expect(searchBtn).toBeDefined();
|
||||
|
||||
await searchBtn!.trigger('click');
|
||||
expect(wrapper.emitted()).toHaveProperty('load');
|
||||
});
|
||||
|
||||
it('should emit resetQuery event when reset button is clicked', async () => {
|
||||
const wrapper = mount(MeetingQueryToolbar, {
|
||||
global: {
|
||||
plugins: [ElementPlus]
|
||||
},
|
||||
props: {
|
||||
queryForm: {}
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAll('.el-button');
|
||||
const resetBtn = buttons.find(b => b.text().includes('重置'));
|
||||
expect(resetBtn).toBeDefined();
|
||||
|
||||
await resetBtn!.trigger('click');
|
||||
expect(wrapper.emitted()).toHaveProperty('resetQuery');
|
||||
});
|
||||
});
|
||||
@@ -106,9 +106,9 @@
|
||||
<el-form-item v-if="projectForm.allowProjectOverBudget" label="超支阈值">
|
||||
<el-input-number v-model="projectForm.overBudgetThresholdRatio" :min="0" :max="1" :step="0.01" class="project-number-input" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发票信息">
|
||||
<!-- <el-form-item label="发票信息">
|
||||
<el-input v-model="projectForm.invoiceInfo" />
|
||||
</el-form-item>
|
||||
</el-form-item> -->
|
||||
<el-form-item label="主办单位负责人">
|
||||
<el-input :model-value="projectForm.hostOwnerUsers || '系统自动取TENANT_ADMIN'" disabled />
|
||||
</el-form-item>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user