This commit is contained in:
haomingming
2026-06-16 10:32:04 +08:00
parent 52fd2e7560
commit e0b089fbf8
53 changed files with 3674 additions and 577 deletions
+2353 -1
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -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"
}
}
+26 -5
View File
@@ -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 {
+3
View File
@@ -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",
+38 -2
View File
@@ -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]);
};
+1
View File
@@ -65,6 +65,7 @@ const STATUS_TEXT_MAP: Record<string, string> = {
AUDIT_RETURNED: "审核退回",
FINANCE_CONFIRMED: "财务已确认",
USER_CREATED: "用户创建",
USER_PASSWORD_RESET: "密码重置",
DELIVERED: "已送达",
UNREAD: "未读",
READ: "已读",
+18 -3
View File
@@ -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();
}
+1 -1
View File
@@ -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>
+2 -1
View File
@@ -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) => {
+41 -36
View File
@@ -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" />
+65 -128
View File
@@ -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>
+7 -7
View File
@@ -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();
+152 -26
View File
@@ -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>
+16 -11
View File
@@ -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">
+14 -35
View File
@@ -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>
+4
View File
@@ -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,