| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | /** 审æ¹ç±»åï¼ä¸åç«¯åæ®µ approvalType 对é½ï¼åæå¯åæ¥ï¼ */ |
| | | export const APPROVAL_TYPE_OPTIONS = [ |
| | | { value: "cost_reimburse", label: "è´¹ç¨æ¥éç³è¯·", cellBg: "#e8f8ef", cellColor: "#1a7f4b" }, |
| | | { value: "travel_reimburse", label: "å·®æ
æ¥éç³è¯·", cellBg: "#f0f2f5", cellColor: "#606266" }, |
| | | { value: "overtime", label: "å çç³è¯·", cellBg: "#fdf3e8", cellColor: "#c45c26" }, |
| | | { value: "leave", label: "请åç³è¯·", cellBg: "#fce8f0", cellColor: "#b84d7a" }, |
| | | { value: "work_handover", label: "å·¥ä½äº¤æ¥ç³è¯·", cellBg: "#f0e8fc", cellColor: "#6b4d9e" }, |
| | | { value: "regular", label: "转æ£ç³è¯·", cellBg: "#e8f4fc", cellColor: "#2b6cb0" }, |
| | | { value: "resign", label: "离èç³è¯·", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" }, |
| | | { value: "transfer", label: "è°å²ç³è¯·", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" }, |
| | | { value: "out_office", label: "å
¬åºç³è¯·", cellBg: "#e8f4ff", cellColor: "#409eff" }, |
| | | { value: "business_trip", label: "åºå·®ç³è¯·", cellBg: "#fdf6ec", cellColor: "#e6a23c" }, |
| | | { value: "procurement", label: "éè´å®¡æ¹", cellBg: "#f4f4f5", cellColor: "#909399" }, |
| | | { value: "quotation", label: "æ¥ä»·å®¡æ¹", cellBg: "#f4ecfc", cellColor: "#9b59b6" }, |
| | | { value: "shipment", label: "å货审æ¹", cellBg: "#e8faf6", cellColor: "#1abc9c" }, |
| | | ]; |
| | | |
| | | /** 审æ¹ç¶æ approvalStatus */ |
| | | export const APPROVAL_STATUS_OPTIONS = [ |
| | | { value: "pending", label: "å®¡æ ¸ä¸" }, |
| | | { value: "approved", label: "å·²éè¿" }, |
| | | { value: "rejected", label: "已驳å" }, |
| | | { value: "cancelled", label: "å·²æ¤é" }, |
| | | ]; |
| | | |
| | | /** å®¡æ¹æ¹å¼ approvalMode */ |
| | | export const APPROVAL_MODE_OPTIONS = [ |
| | | { value: "parallel", label: "ä¸ç¾" }, |
| | | { value: "or_sign", label: "æç¾" }, |
| | | ]; |
| | | |
| | | /** |
| | | * æäº¤å®¡æ¹æ¨¡æ¿ï¼æç±»åä¸é®å¡«æ¥ï¼å段åæä¸å端模æ¿åæ¥ï¼ |
| | | */ |
| | | export const SUBMIT_TEMPLATES = { |
| | | cost_reimburse: { |
| | | approvalType: "cost_reimburse", |
| | | label: "è´¹ç¨æ¥é", |
| | | summaryPlaceholder: "è¯·å¡«åæ¥éäºç±ãéé¢ç", |
| | | fields: [ |
| | | { key: "summary", label: "ç³è¯·äºç±", type: "textarea", required: true, rows: 3 }, |
| | | { key: "amount", label: "æ¥ééé¢(å
)", type: "number", required: true, min: 0, precision: 2 }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | travel_reimburse: { |
| | | approvalType: "travel_reimburse", |
| | | label: "å·®æ
æ¥é", |
| | | summaryPlaceholder: "åºå·®è¡ç¨ä¸è´¹ç¨è¯´æ", |
| | | fields: [ |
| | | { key: "summary", label: "å·®æ
说æ", type: "textarea", required: true, rows: 3 }, |
| | | { key: "amount", label: "æ¥ééé¢(å
)", type: "number", required: true, min: 0, precision: 2 }, |
| | | { key: "tripDays", label: "åºå·®å¤©æ°", type: "number", required: false, min: 0, precision: 0 }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | overtime: { |
| | | approvalType: "overtime", |
| | | label: "å çç³è¯·", |
| | | fields: [ |
| | | { key: "summary", label: "å çäºç±", type: "textarea", required: true, rows: 3 }, |
| | | { key: "overtimeDate", label: "å çæ¥æ", type: "date", required: true }, |
| | | { key: "hours", label: "å çæ¶é¿(å°æ¶)", type: "number", required: true, min: 0.5, precision: 1 }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | leave: { |
| | | approvalType: "leave", |
| | | label: "请åç³è¯·", |
| | | fields: [ |
| | | { key: "leaveType", label: "请åç±»å", type: "select", required: true, options: [ |
| | | { label: "å¹´å", value: "annual" }, |
| | | { label: "ç
å", value: "sick" }, |
| | | { label: "äºå", value: "personal" }, |
| | | { label: "è°ä¼", value: "compensatory" }, |
| | | ] }, |
| | | { key: "summary", label: "请åäºç±", type: "textarea", required: true, rows: 2 }, |
| | | { key: "dateRange", label: "è¯·åæ¶é´", type: "datetimerange", required: true }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | work_handover: { |
| | | approvalType: "work_handover", |
| | | label: "å·¥ä½äº¤æ¥", |
| | | fields: [ |
| | | { key: "summary", label: "交æ¥è¯´æ", type: "textarea", required: true, rows: 3 }, |
| | | { key: "handoverTo", label: "交æ¥å¯¹è±¡", type: "text", required: true }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | regular: { |
| | | approvalType: "regular", |
| | | label: "转æ£ç³è¯·", |
| | | fields: [ |
| | | { key: "summary", label: "转æ£è¯´æ", type: "textarea", required: true, rows: 3 }, |
| | | { key: "regularDate", label: "æè½¬æ£æ¥æ", type: "date", required: true }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | resign: { |
| | | approvalType: "resign", |
| | | label: "离èç³è¯·", |
| | | fields: [ |
| | | { key: "summary", label: "离èåå ", type: "textarea", required: true, rows: 3 }, |
| | | { key: "lastWorkDay", label: "æå工使¥", type: "date", required: true }, |
| | | ], |
| | | approvalMode: "or_sign", |
| | | }, |
| | | transfer: { |
| | | approvalType: "transfer", |
| | | label: "è°å²ç³è¯·", |
| | | fields: [ |
| | | { key: "summary", label: "è°å²è¯´æ", type: "textarea", required: true, rows: 2 }, |
| | | { key: "targetDept", label: "ç®æ é¨é¨", type: "text", required: true }, |
| | | { key: "targetPost", label: "ç®æ å²ä½", type: "text", required: true }, |
| | | ], |
| | | approvalMode: "parallel", |
| | | }, |
| | | }; |
| | | |
| | | export const STORAGE_KEY = "oa_unified_approve_list_v1"; |
| | | |
| | | export function approvalTypeLabel(v) { |
| | | return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function approvalTypeStyle(v) { |
| | | const hit = APPROVAL_TYPE_OPTIONS.find((x) => x.value === v); |
| | | if (!hit) return {}; |
| | | return { |
| | | backgroundColor: hit.cellBg, |
| | | color: hit.cellColor, |
| | | border: hit.border || "none", |
| | | }; |
| | | } |
| | | |
| | | export function approvalStatusLabel(v) { |
| | | return APPROVAL_STATUS_OPTIONS.find((x) => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function approvalStatusTagType(v) { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | if (v === "cancelled") return "info"; |
| | | return "primary"; |
| | | } |
| | | |
| | | export function approvalModeLabel(v) { |
| | | if (v === "countersign") return "æç¾"; |
| | | return APPROVAL_MODE_OPTIONS.find((x) => x.value === v)?.label || "ä¸ç¾"; |
| | | } |
| | | |
| | | export function unreadLabel(v) { |
| | | return v ? "æ¯" : "å¦"; |
| | | } |
| | | |
| | | export function buildDefaultFlowNodes() { |
| | | return [ |
| | | { |
| | | approverId: "mock_supervisor", |
| | | approverName: "ç´å±ä¸çº§", |
| | | sortOrder: 1, |
| | | nodeOrder: 1, |
| | | nodeStatus: "process", |
| | | approveOpinion: "", |
| | | approveTime: "", |
| | | }, |
| | | { |
| | | approverId: "mock_manager", |
| | | approverName: "é¨é¨ç»ç", |
| | | sortOrder: 2, |
| | | nodeOrder: 2, |
| | | nodeStatus: "wait", |
| | | approveOpinion: "", |
| | | approveTime: "", |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | function demoRow(partial) { |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | return { |
| | | id: partial.id, |
| | | bizId: partial.bizId || partial.id, |
| | | applicantNo: partial.applicantNo, |
| | | applicantName: partial.applicantName, |
| | | approvalType: partial.approvalType, |
| | | approvalMode: partial.approvalMode || "parallel", |
| | | unread: partial.unread ?? false, |
| | | approvalStatus: partial.approvalStatus || "pending", |
| | | createTime: partial.createTime || now, |
| | | summary: partial.summary || "", |
| | | formPayload: partial.formPayload || {}, |
| | | approvalFlowNodes: partial.approvalFlowNodes || buildDefaultFlowNodes(), |
| | | currentNodeIndex: partial.currentNodeIndex ?? 0, |
| | | approvalRecords: partial.approvalRecords || [], |
| | | rejectReason: partial.rejectReason || "", |
| | | sourceRoute: partial.sourceRoute || "", |
| | | }; |
| | | } |
| | | |
| | | /** åå§æ¼ç¤ºæ°æ®ï¼å
± 22 æ¡ï¼ä¸ååæ°éä¸è´ï¼ */ |
| | | export function createInitialMockRows() { |
| | | const types = [ |
| | | "cost_reimburse", |
| | | "travel_reimburse", |
| | | "overtime", |
| | | "leave", |
| | | "work_handover", |
| | | "regular", |
| | | "resign", |
| | | "transfer", |
| | | "cost_reimburse", |
| | | "leave", |
| | | "overtime", |
| | | "travel_reimburse", |
| | | "work_handover", |
| | | "regular", |
| | | "cost_reimburse", |
| | | "leave", |
| | | "transfer", |
| | | "resign", |
| | | "overtime", |
| | | "travel_reimburse", |
| | | "cost_reimburse", |
| | | "leave", |
| | | ]; |
| | | const applicants = [ |
| | | { no: "007", name: "è¹æ" }, |
| | | { no: "Guest001", name: "å¤é¨ç¨æ·" }, |
| | | { no: "0056", name: "çäº" }, |
| | | { no: "0042", name: "æå" }, |
| | | { no: "0088", name: "ç«ç«" }, |
| | | { no: "0012", name: "å¼ ä¸" }, |
| | | { no: "0033", name: "èµµå
" }, |
| | | ]; |
| | | const summaries = [ |
| | | "åå
¬ç¨åéè´æ¥é", |
| | | "䏿µ·åºå·®å·®æ
è´¹", |
| | | "卿«é¡¹ç®å ç", |
| | | "å¹´å 3 天", |
| | | "离èå·¥ä½äº¤æ¥", |
| | | "è¯ç¨æè½¬æ£ç³è¯·", |
| | | "个人åå 离è", |
| | | "è°è³éå®é¨", |
| | | "å®¢æ·æ¥å¾
é¤è´¹", |
| | | "ç
å 1 天", |
| | | "è忥å¼çå ç", |
| | | "å京å¹è®å·®æ
", |
| | | "é¡¹ç®ææ¡£äº¤æ¥", |
| | | "ç åå²è½¬æ£", |
| | | "é讯费æ¥é", |
| | | "äºåå天", |
| | | "è°å²è³å¸åºé¨", |
| | | "åå离è", |
| | | "工使¥å»¶æ¶å ç", |
| | | "æé½å±ä¼å·®æ
", |
| | | "交éè´¹æ¥é", |
| | | "è°ä¼ 1 天", |
| | | ]; |
| | | const statuses = ["pending", "pending", "pending", "approved", "pending", "pending", "rejected", "pending"]; |
| | | return types.map((approvalType, i) => { |
| | | const ap = applicants[i % applicants.length]; |
| | | const daysAgo = i % 14; |
| | | return demoRow({ |
| | | id: `mock_${i + 1}`, |
| | | bizId: `BIZ${String(2025031400 + i)}`, |
| | | applicantNo: ap.no, |
| | | applicantName: ap.name, |
| | | approvalType, |
| | | approvalMode: i % 5 === 0 ? "or_sign" : "parallel", |
| | | unread: i % 3 === 0, |
| | | approvalStatus: statuses[i % statuses.length], |
| | | createTime: dayjs().subtract(daysAgo, "day").hour(9 + (i % 8)).minute((i * 7) % 60).second(0).format("YYYY-MM-DD HH:mm:ss"), |
| | | summary: summaries[i], |
| | | formPayload: { summary: summaries[i] }, |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | export function loadStoredRows() { |
| | | try { |
| | | const raw = localStorage.getItem(STORAGE_KEY); |
| | | if (!raw) return null; |
| | | const parsed = JSON.parse(raw); |
| | | return Array.isArray(parsed) ? parsed : null; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function saveStoredRows(rows) { |
| | | try { |
| | | localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); |
| | | } catch { |
| | | /* ignore quota */ |
| | | } |
| | | } |
| | | |
| | | export function createEmptySubmitForm(templateKey) { |
| | | const tpl = SUBMIT_TEMPLATES[templateKey]; |
| | | const payload = { summary: "" }; |
| | | (tpl?.fields || []).forEach((f) => { |
| | | if (f.type === "number") payload[f.key] = undefined; |
| | | else if (f.type === "datetimerange") payload[f.key] = []; |
| | | else payload[f.key] = ""; |
| | | }); |
| | | return { |
| | | templateKey: templateKey || "", |
| | | approvalMode: tpl?.approvalMode || "parallel", |
| | | formPayload: payload, |
| | | approvalFlowNodes: buildDefaultFlowNodes(), |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- ç»ä¸å®¡æ¹ï¼ä¸å¡æè¦ --> |
| | | <template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="ä¸å¡åå·">{{ row.bizId || row.id || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç¶æ"> |
| | | <el-tag :type="approvalStatusTagType(row.approvalStatus)" size="small" effect="plain"> |
| | | {{ approvalStatusLabel(row.approvalStatus) }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç±»å"> |
| | | <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)"> |
| | | {{ approvalTypeLabel(row.approvalType) }} |
| | | </span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ¹æ¹å¼"> |
| | | <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·äººç¼å·">{{ row.applicantNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·äººåç§°">{{ row.applicantName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·æè¦" :span="2">{{ row.summary || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item v-if="row.rejectReason" label="驳ååå " :span="2"> |
| | | <span class="reject-text">{{ row.rejectReason }}</span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´" :span="2">{{ row.createTime || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <template v-if="extraFields.length"> |
| | | <el-divider content-position="left">å¡«æ¥å
容</el-divider> |
| | | <el-descriptions :column="2" border size="small"> |
| | | <el-descriptions-item v-for="item in extraFields" :key="item.key" :label="item.label"> |
| | | {{ item.display }} |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | </template> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { |
| | | approvalTypeLabel, |
| | | approvalTypeStyle, |
| | | approvalModeLabel, |
| | | approvalStatusLabel, |
| | | approvalStatusTagType, |
| | | SUBMIT_TEMPLATES, |
| | | } from "../approveListConstants.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const extraFields = computed(() => { |
| | | const payload = props.row?.formPayload || {}; |
| | | const tpl = Object.values(SUBMIT_TEMPLATES).find((t) => t.approvalType === props.row?.approvalType); |
| | | if (!tpl?.fields?.length) { |
| | | return Object.keys(payload) |
| | | .filter((k) => k !== "summary" && payload[k] != null && payload[k] !== "") |
| | | .map((k) => ({ key: k, label: k, display: formatValue(payload[k]) })); |
| | | } |
| | | return tpl.fields |
| | | .map((f) => { |
| | | const val = payload[f.key]; |
| | | if (val == null || val === "" || (Array.isArray(val) && !val.length)) return null; |
| | | let display = formatValue(val); |
| | | if (f.type === "select" && f.options) { |
| | | display = f.options.find((o) => o.value === val)?.label || display; |
| | | } |
| | | return { key: f.key, label: f.label, display }; |
| | | }) |
| | | .filter(Boolean); |
| | | }); |
| | | |
| | | function formatValue(val) { |
| | | if (Array.isArray(val)) return val.join(" è³ "); |
| | | return String(val); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .approve-type-cell { |
| | | display: inline-block; |
| | | padding: 2px 10px; |
| | | border-radius: 4px; |
| | | font-size: 13px; |
| | | line-height: 1.5; |
| | | } |
| | | .approval-method-text { |
| | | color: var(--el-color-danger); |
| | | font-weight: 500; |
| | | } |
| | | .reject-text { |
| | | color: var(--el-color-danger); |
| | | } |
| | | </style> |
| | |
| | | <!-- |
| | | 模å䏿åï¼å®¡æ¹å表 |
| | | ç®å½æ è¯ï¼ApproveManage/approve-listï¼approve-list â 䏿ï¼å®¡æ¹åè¡¨ï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <!--OA模åï¼å®¡æ¹å表--> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div class="search_fields"> |
| | | <span class="search_title">审æ¹ç±»åï¼</span> |
| | | <el-select |
| | | v-model="searchForm.approvalType" |
| | | placeholder="è¯·éæ©å®¡æ¹ç±»å" |
| | | clearable |
| | | filterable |
| | | style="width: 200px" |
| | | > |
| | | <el-option |
| | | v-for="opt in APPROVAL_TYPE_OPTIONS" |
| | | :key="opt.value" |
| | | :label="opt.label" |
| | | :value="opt.value" |
| | | /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">ç³è¯·äººåç§°ï¼</span> |
| | | <el-input |
| | | v-model="searchForm.applicantKeyword" |
| | | style="width: 200px" |
| | | placeholder="请è¾å
¥ç³è¯·äººåç§°" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <span class="search_title" style="margin-left: 12px">å建æ¶é´ï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.createTimeRange" |
| | | type="daterange" |
| | | range-separator="-" |
| | | start-placeholder="å¼å§æ¥æ" |
| | | end-placeholder="ç»ææ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 260px" |
| | | clearable |
| | | /> |
| | | <el-button type="primary" :icon="Search" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button :icon="RefreshRight" @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div class="search_actions"> |
| | | <el-button type="primary" :icon="Plus" @click="openSubmitDialog">æäº¤å®¡æ¹</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | :total="page.total" |
| | | @pagination="pagination" |
| | | > |
| | | <template #approveType="{ row }"> |
| | | <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)"> |
| | | {{ approvalTypeLabel(row.approvalType) }} |
| | | </span> |
| | | </template> |
| | | <template #approvalMethod="{ row }"> |
| | | <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span> |
| | | </template> |
| | | </PIMTable> |
| | | </div> |
| | | |
| | | <!-- æäº¤å®¡æ¹ï¼ææ¨¡æ¿ï¼ --> |
| | | <el-dialog |
| | | v-model="submitDialog.visible" |
| | | :title="submitDialog.step === 1 ? 'éæ©å®¡æ¹æ¨¡æ¿' : `æäº¤${activeTemplate?.label || '审æ¹'}`" |
| | | width="720px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="approve-submit-dialog" |
| | | @closed="submitDialog.step = 1" |
| | | > |
| | | <template v-if="submitDialog.step === 1"> |
| | | <p class="template-hint">è¯·éæ©è¦æäº¤ç审æ¹ç±»åï¼ç³»ç»å°æå¯¹åºæ¨¡æ¿å¼å¯¼å¡«æ¥ï¼å段åæä¸åç«¯åæ¥ï¼ã</p> |
| | | <div class="template-grid"> |
| | | <div |
| | | v-for="(tpl, key) in SUBMIT_TEMPLATES" |
| | | :key="key" |
| | | class="template-card" |
| | | @click="onTemplatePick(key)" |
| | | > |
| | | <span class="template-card-type" :style="approvalTypeStyle(tpl.approvalType)"> |
| | | {{ tpl.label }} |
| | | </span> |
| | | <span class="template-card-desc">{{ tpl.summaryPlaceholder || "ç¹å»å¡«åå¹¶æäº¤" }}</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <template v-else> |
| | | <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px"> |
| | | <el-form-item label="审æ¹ç±»å"> |
| | | <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)"> |
| | | {{ activeTemplate.label }} |
| | | </span> |
| | | <el-button type="primary" link class="ml12" @click="backToTemplatePick">æ´æ¢æ¨¡æ¿</el-button> |
| | | </el-form-item> |
| | | <el-form-item label="å®¡æ¹æ¹å¼" prop="approvalMode"> |
| | | <el-radio-group v-model="submitForm.approvalMode"> |
| | | <el-radio value="parallel">ä¸ç¾</el-radio> |
| | | <el-radio value="or_sign">æç¾</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | <template v-for="field in activeTemplate.fields" :key="field.key"> |
| | | <el-form-item :label="field.label" :prop="`formPayload.${field.key}`"> |
| | | <el-input |
| | | v-if="field.type === 'text'" |
| | | v-model="submitForm.formPayload[field.key]" |
| | | :placeholder="`请è¾å
¥${field.label}`" |
| | | maxlength="200" |
| | | /> |
| | | <el-input |
| | | v-else-if="field.type === 'textarea'" |
| | | v-model="submitForm.formPayload[field.key]" |
| | | type="textarea" |
| | | :rows="field.rows || 3" |
| | | :placeholder="`请填å${field.label}`" |
| | | maxlength="2000" |
| | | show-word-limit |
| | | /> |
| | | <el-input-number |
| | | v-else-if="field.type === 'number'" |
| | | v-model="submitForm.formPayload[field.key]" |
| | | :min="field.min ?? 0" |
| | | :precision="field.precision ?? 0" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | /> |
| | | <el-date-picker |
| | | v-else-if="field.type === 'date'" |
| | | v-model="submitForm.formPayload[field.key]" |
| | | type="date" |
| | | :placeholder="`è¯·éæ©${field.label}`" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 100%" |
| | | /> |
| | | <el-date-picker |
| | | v-else-if="field.type === 'datetimerange'" |
| | | v-model="submitForm.formPayload[field.key]" |
| | | type="datetimerange" |
| | | range-separator="è³" |
| | | start-placeholder="å¼å§æ¶é´" |
| | | end-placeholder="ç»ææ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | /> |
| | | <el-select |
| | | v-else-if="field.type === 'select'" |
| | | v-model="submitForm.formPayload[field.key]" |
| | | :placeholder="`è¯·éæ©${field.label}`" |
| | | style="width: 100%" |
| | | clearable |
| | | > |
| | | <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </template> |
| | | <el-form-item label="å®¡æ¹æµç¨"> |
| | | <ApprovalFlowEditor v-model="submitForm.approvalFlowNodes" :user-options="flowUserOptions" /> |
| | | <p class="flow-tip">è³å°ä¿çä¸ä¸ªå®¡æ¹èç¹ï¼æäº¤åè¿å
¥ãå®¡æ ¸ä¸ãç¶æã</p> |
| | | </el-form-item> |
| | | </el-form> |
| | | </template> |
| | | |
| | | <template #footer> |
| | | <el-button v-if="submitDialog.step === 2" type="primary" @click="onSubmitNew">æ 交</el-button> |
| | | <el-button @click="submitDialog.visible = false">{{ submitDialog.step === 1 ? "å æ¶" : "å
³ é" }}</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog |
| | | v-model="detailDialog.visible" |
| | | title="审æ¹è¯¦æ
" |
| | | width="920px" |
| | | append-to-body |
| | | destroy-on-close |
| | | > |
| | | <ApproveDetailPanel :row="detailRow" /> |
| | | <el-divider content-position="left">å®¡æ¹æµç¨</el-divider> |
| | | <ApprovalFlowProgress |
| | | :nodes="detailRow.approvalFlowNodes" |
| | | :current-index="detailRow.currentNodeIndex ?? 0" |
| | | /> |
| | | <el-divider content-position="left">审æ¹è®°å½</el-divider> |
| | | <el-timeline v-if="detailRow.approvalRecords?.length"> |
| | | <el-timeline-item |
| | | v-for="(rec, i) in detailRow.approvalRecords" |
| | | :key="i" |
| | | :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'" |
| | | :timestamp="rec.time" |
| | | > |
| | | {{ rec.operatorName }} â {{ approvalActionLabel(rec.result) }}ï¼{{ rec.opinion || "æ æè§" }} |
| | | </el-timeline-item> |
| | | </el-timeline> |
| | | <el-empty v-else description="ææ å®¡æ¹è®°å½" :image-size="60" /> |
| | | <template #footer> |
| | | <el-button |
| | | v-if="detailRow.approvalStatus === 'pending'" |
| | | type="primary" |
| | | @click="openApproveFromDetail" |
| | | > |
| | | å»å®¡æ¹ |
| | | </el-button> |
| | | <el-button @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- å®¡æ¹æä½ --> |
| | | <el-dialog |
| | | v-model="approveDialog.visible" |
| | | title="审æ¹å¤ç" |
| | | width="960px" |
| | | append-to-body |
| | | destroy-on-close |
| | | @closed="approveOpinion = ''" |
| | | > |
| | | <ApproveDetailPanel :row="approveDialog.row" /> |
| | | <el-divider content-position="left">æµç¨è¿åº¦</el-divider> |
| | | <ApprovalFlowProgress |
| | | :nodes="approveDialog.row?.approvalFlowNodes" |
| | | :current-index="approveDialog.row?.currentNodeIndex ?? 0" |
| | | /> |
| | | <el-form label-width="100px" class="mt16"> |
| | | <el-form-item label="å®¡æ¹æè§" required> |
| | | <el-input |
| | | v-model="approveOpinion" |
| | | type="textarea" |
| | | :rows="3" |
| | | maxlength="500" |
| | | show-word-limit |
| | | placeholder="éè¿å¯ç空ï¼é©³å请填åå
·ä½åå " |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button type="success" @click="onApprove('approved')">é è¿</el-button> |
| | | <el-button type="danger" @click="onApprove('rejected')">驳 å</el-button> |
| | | <el-button @click="approveDialog.visible = false">å æ¶</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue' |
| | | import { Plus, RefreshRight } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import { onMounted, ref } from "vue"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue"; |
| | | import ApprovalFlowProgress from "@/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue"; |
| | | import { approvalTypeStyle } from "./approveListConstants.js"; |
| | | import ApproveDetailPanel from "./components/ApproveDetailPanel.vue"; |
| | | import { useApproveList } from "./useApproveList.js"; |
| | | |
| | | const al = useApproveList(); |
| | | const { |
| | | Search, |
| | | APPROVAL_TYPE_OPTIONS, |
| | | SUBMIT_TEMPLATES, |
| | | approvalTypeLabel, |
| | | approvalModeLabel, |
| | | approvalActionLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | detailDialog, |
| | | detailRow, |
| | | approveDialog, |
| | | approveOpinion, |
| | | submitDialog, |
| | | submitForm, |
| | | submitFormRef, |
| | | activeTemplate, |
| | | submitFormRules, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openSubmitDialog, |
| | | onTemplatePick, |
| | | backToTemplatePick, |
| | | submitNewApproval, |
| | | submitApprove, |
| | | openDetail, |
| | | openApprove, |
| | | } = al; |
| | | |
| | | const flowUserOptions = ref([]); |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload?.data && Array.isArray(payload.data)) return payload.data; |
| | | if (payload?.rows && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | async function loadUsers() { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | flowUserOptions.value = unwrapArray(res).filter(isActiveUser); |
| | | } catch { |
| | | flowUserOptions.value = []; |
| | | } |
| | | } |
| | | |
| | | async function onSubmitNew() { |
| | | const ok = await submitNewApproval(); |
| | | if (ok) ElMessage.success("审æ¹å·²æäº¤"); |
| | | } |
| | | |
| | | function onApprove(result) { |
| | | const ret = submitApprove(result); |
| | | if (ret?.needOpinion) { |
| | | ElMessage.warning("é©³åæ¶è¯·å¡«åå®¡æ¹æè§"); |
| | | return; |
| | | } |
| | | if (ret?.ok) { |
| | | ElMessage.success(result === "approved" ? "å·²éè¿" : "已驳å"); |
| | | } |
| | | } |
| | | |
| | | function openApproveFromDetail() { |
| | | const row = detailRow.value; |
| | | detailDialog.visible = false; |
| | | openApprove(row); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadUsers(); |
| | | handleQuery(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .ml12 { |
| | | margin-left: 12px; |
| | | } |
| | | .mt16 { |
| | | margin-top: 16px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_fields { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | .search_actions { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | } |
| | | .approve-type-cell { |
| | | display: inline-block; |
| | | padding: 2px 10px; |
| | | border-radius: 4px; |
| | | font-size: 13px; |
| | | line-height: 1.5; |
| | | } |
| | | .approval-method-text { |
| | | color: var(--el-color-danger); |
| | | font-weight: 500; |
| | | } |
| | | .template-hint { |
| | | font-size: 13px; |
| | | color: var(--el-text-color-secondary); |
| | | margin: 0 0 16px; |
| | | } |
| | | .template-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
| | | gap: 12px; |
| | | } |
| | | .template-card { |
| | | padding: 14px 16px; |
| | | border: 1px solid var(--el-border-color-lighter); |
| | | border-radius: var(--radius-md, 8px); |
| | | cursor: pointer; |
| | | transition: border-color 0.2s, box-shadow 0.2s; |
| | | background: var(--el-fill-color-blank); |
| | | } |
| | | .template-card:hover { |
| | | border-color: var(--el-color-primary); |
| | | box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06)); |
| | | } |
| | | .template-card-type { |
| | | display: inline-block; |
| | | padding: 2px 8px; |
| | | border-radius: 4px; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | margin-bottom: 8px; |
| | | } |
| | | .template-card-desc { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | line-height: 1.5; |
| | | } |
| | | .flow-tip { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | margin-top: 8px; |
| | | } |
| | | .approve-submit-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { |
| | | APPROVAL_TYPE_OPTIONS, |
| | | SUBMIT_TEMPLATES, |
| | | approvalModeLabel, |
| | | approvalStatusLabel, |
| | | approvalStatusTagType, |
| | | approvalTypeLabel, |
| | | createEmptySubmitForm, |
| | | createInitialMockRows, |
| | | loadStoredRows, |
| | | saveStoredRows, |
| | | buildDefaultFlowNodes, |
| | | } from "./approveListConstants.js"; |
| | | |
| | | function advanceFlow(row, result, opinion) { |
| | | const nodes = row.approvalFlowNodes || []; |
| | | const idx = row.currentNodeIndex ?? 0; |
| | | const node = nodes[idx]; |
| | | if (!node) return; |
| | | node.nodeStatus = result === "approved" ? "finish" : "error"; |
| | | node.approveOpinion = opinion || (result === "approved" ? "åæ" : "驳å"); |
| | | node.approveTime = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | row.approvalRecords = row.approvalRecords || []; |
| | | row.approvalRecords.push({ |
| | | operatorName: node.approverName || "审æ¹äºº", |
| | | result, |
| | | opinion: node.approveOpinion, |
| | | time: node.approveTime, |
| | | }); |
| | | if (result === "rejected") { |
| | | row.approvalStatus = "rejected"; |
| | | row.rejectReason = opinion || node.approveOpinion; |
| | | return; |
| | | } |
| | | const next = idx + 1; |
| | | if (next < nodes.length) { |
| | | row.currentNodeIndex = next; |
| | | nodes[next].nodeStatus = "process"; |
| | | row.approvalStatus = "pending"; |
| | | } else { |
| | | row.approvalStatus = "approved"; |
| | | row.rejectReason = ""; |
| | | } |
| | | } |
| | | |
| | | export function useApproveList() { |
| | | const userStore = useUserStore(); |
| | | const stored = loadStoredRows(); |
| | | const allRows = ref(stored?.length ? stored : createInitialMockRows()); |
| | | |
| | | const searchForm = reactive({ |
| | | approvalType: "", |
| | | applicantKeyword: "", |
| | | createTimeRange: [], |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const approveDialog = reactive({ visible: false, row: null }); |
| | | const approveOpinion = ref(""); |
| | | |
| | | const submitDialog = reactive({ visible: false, step: 1 }); |
| | | const submitForm = reactive(createEmptySubmitForm("")); |
| | | const submitFormRef = ref(); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | if (searchForm.approvalType) { |
| | | list = list.filter((r) => r.approvalType === searchForm.approvalType); |
| | | } |
| | | const kw = (searchForm.applicantKeyword || "").trim().toLowerCase(); |
| | | if (kw) { |
| | | list = list.filter((r) => { |
| | | const name = (r.applicantName || "").toLowerCase(); |
| | | const no = (r.applicantNo || "").toLowerCase(); |
| | | return name.includes(kw) || no.includes(kw); |
| | | }); |
| | | } |
| | | const range = searchForm.createTimeRange; |
| | | if (range?.length === 2) { |
| | | const [from, to] = range; |
| | | list = list.filter((r) => { |
| | | const t = (r.createTime || "").slice(0, 10); |
| | | return t && t >= from && t <= to; |
| | | }); |
| | | } |
| | | return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1)); |
| | | }); |
| | | |
| | | watch( |
| | | filteredList, |
| | | (list) => { |
| | | page.total = list.length; |
| | | const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); |
| | | if (page.current > maxPage) page.current = maxPage; |
| | | }, |
| | | { immediate: true } |
| | | ); |
| | | |
| | | const tableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return filteredList.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const activeTemplate = computed(() => SUBMIT_TEMPLATES[submitForm.templateKey] || null); |
| | | |
| | | const submitFormRules = computed(() => { |
| | | const rules = { |
| | | templateKey: [{ required: true, message: "è¯·éæ©å®¡æ¹ç±»å", trigger: "change" }], |
| | | }; |
| | | (activeTemplate.value?.fields || []).forEach((f) => { |
| | | if (!f.required) return; |
| | | if (f.type === "number") { |
| | | rules[`formPayload.${f.key}`] = [{ required: true, message: `请填å${f.label}`, trigger: "blur" }]; |
| | | } else if (f.type === "datetimerange") { |
| | | rules[`formPayload.${f.key}`] = [{ required: true, message: `è¯·éæ©${f.label}`, trigger: "change" }]; |
| | | } else { |
| | | rules[`formPayload.${f.key}`] = [{ required: true, message: `请填å${f.label}`, trigger: "blur" }]; |
| | | } |
| | | }); |
| | | return rules; |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç³è¯·äººç¼å·", prop: "applicantNo", width: 110 }, |
| | | { label: "ç³è¯·äººåç§°", prop: "applicantName", minWidth: 100 }, |
| | | { |
| | | label: "审æ¹ç±»å", |
| | | prop: "approvalType", |
| | | minWidth: 140, |
| | | dataType: "slot", |
| | | slot: "approveType", |
| | | }, |
| | | { |
| | | label: "å®¡æ¹æ¹å¼", |
| | | prop: "approvalMode", |
| | | width: 90, |
| | | dataType: "slot", |
| | | slot: "approvalMethod", |
| | | }, |
| | | { |
| | | label: "æ¯å¦æªè¯»", |
| | | prop: "unread", |
| | | width: 90, |
| | | align: "center", |
| | | formatData: (v) => (v ? "æ¯" : "å¦"), |
| | | }, |
| | | { |
| | | label: "审æ¹ç¶æ", |
| | | prop: "approvalStatus", |
| | | width: 100, |
| | | dataType: "tag", |
| | | formatData: (v) => approvalStatusLabel(v), |
| | | formatType: (v) => approvalStatusTagType(v), |
| | | }, |
| | | { label: "å建æ¶é´", prop: "createTime", width: 170 }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 160, |
| | | operation: [ |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { |
| | | name: "审æ¹", |
| | | type: "text", |
| | | disabled: (row) => row.approvalStatus !== "pending", |
| | | clickFun: (row) => openApprove(row), |
| | | }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | function persist() { |
| | | saveStoredRows(allRows.value); |
| | | } |
| | | |
| | | function handleQuery() { |
| | | tableLoading.value = true; |
| | | page.current = 1; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 200); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.approvalType = ""; |
| | | searchForm.applicantKeyword = ""; |
| | | searchForm.createTimeRange = []; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination({ page: p, limit }) { |
| | | page.current = p; |
| | | page.size = limit; |
| | | } |
| | | |
| | | function markRead(row) { |
| | | if (!row.unread) return; |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (hit) { |
| | | hit.unread = false; |
| | | persist(); |
| | | } |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | markRead(row); |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openApprove(row) { |
| | | markRead(row); |
| | | approveDialog.row = { ...row }; |
| | | approveOpinion.value = ""; |
| | | approveDialog.visible = true; |
| | | } |
| | | |
| | | function openSubmitDialog() { |
| | | Object.assign(submitForm, createEmptySubmitForm("")); |
| | | submitDialog.step = 1; |
| | | submitDialog.visible = true; |
| | | } |
| | | |
| | | function onTemplatePick(key) { |
| | | Object.assign(submitForm, createEmptySubmitForm(key)); |
| | | submitDialog.step = 2; |
| | | } |
| | | |
| | | function backToTemplatePick() { |
| | | submitDialog.step = 1; |
| | | } |
| | | |
| | | async function submitNewApproval() { |
| | | if (!submitFormRef.value) return false; |
| | | try { |
| | | await submitFormRef.value.validate(); |
| | | } catch { |
| | | return false; |
| | | } |
| | | const tpl = activeTemplate.value; |
| | | if (!tpl) return false; |
| | | const id = `user_${Date.now()}`; |
| | | const summary = |
| | | submitForm.formPayload.summary || |
| | | submitForm.formPayload.handoverTo || |
| | | `${tpl.label}ç³è¯·`; |
| | | const row = { |
| | | id, |
| | | bizId: `BIZ${dayjs().format("YYYYMMDDHHmmss")}`, |
| | | applicantNo: userStore.name || String(userStore.id || "å½åç¨æ·"), |
| | | applicantName: userStore.nickName || userStore.name || "å½åç¨æ·", |
| | | approvalType: tpl.approvalType, |
| | | approvalMode: submitForm.approvalMode, |
| | | unread: false, |
| | | approvalStatus: "pending", |
| | | createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | summary, |
| | | formPayload: { ...submitForm.formPayload }, |
| | | approvalFlowNodes: (submitForm.approvalFlowNodes?.length |
| | | ? submitForm.approvalFlowNodes |
| | | : buildDefaultFlowNodes() |
| | | ).map((n, i) => ({ ...n, nodeStatus: i === 0 ? "process" : n.nodeStatus || "wait" })), |
| | | currentNodeIndex: 0, |
| | | approvalRecords: [], |
| | | rejectReason: "", |
| | | }; |
| | | allRows.value.unshift(row); |
| | | persist(); |
| | | submitDialog.visible = false; |
| | | page.current = 1; |
| | | return true; |
| | | } |
| | | |
| | | function submitApprove(result) { |
| | | const row = approveDialog.row; |
| | | if (!row) return; |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit || hit.approvalStatus !== "pending") return; |
| | | if (result === "rejected" && !(approveOpinion.value || "").trim()) { |
| | | return { needOpinion: true }; |
| | | } |
| | | advanceFlow(hit, result, (approveOpinion.value || "").trim()); |
| | | hit.unread = false; |
| | | persist(); |
| | | approveDialog.visible = false; |
| | | if (detailDialog.visible && detailRow.value?.id === hit.id) { |
| | | detailRow.value = { ...hit }; |
| | | } |
| | | return { ok: true }; |
| | | } |
| | | |
| | | function approvalActionLabel(result) { |
| | | if (result === "approved") return "éè¿"; |
| | | if (result === "rejected") return "驳å"; |
| | | return "å¾
å¤ç"; |
| | | } |
| | | |
| | | return { |
| | | Search, |
| | | APPROVAL_TYPE_OPTIONS, |
| | | SUBMIT_TEMPLATES, |
| | | approvalTypeLabel, |
| | | approvalModeLabel, |
| | | approvalStatusLabel, |
| | | approvalStatusTagType, |
| | | approvalActionLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | detailDialog, |
| | | detailRow, |
| | | approveDialog, |
| | | approveOpinion, |
| | | submitDialog, |
| | | submitForm, |
| | | submitFormRef, |
| | | activeTemplate, |
| | | submitFormRules, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openSubmitDialog, |
| | | onTemplatePick, |
| | | backToTemplatePick, |
| | | submitNewApproval, |
| | | submitApprove, |
| | | openDetail, |
| | | openApprove, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | import { APPROVAL_TYPE_OPTIONS, SUBMIT_TEMPLATES } from "../approve-list/approveListConstants.js"; |
| | | |
| | | /** èç¹å
å®¡æ¹æ¹å¼ï¼ä¼ç¾ / æç¾ */ |
| | | export const NODE_SIGN_MODE_OPTIONS = [ |
| | | { value: "countersign", label: "ä¼ç¾", desc: "æ¬èç¹ææå®¡æ¹äººåééè¿" }, |
| | | { value: "or_sign", label: "æç¾", desc: "æ¬èç¹ä»»ä¸å®¡æ¹äººéè¿å³å¯" }, |
| | | ]; |
| | | |
| | | export const STORAGE_KEY = "oa_approve_template_custom_v1"; |
| | | |
| | | /** ç³»ç»å
置常ç¨å®¡æ¹ï¼åªè¯»å±ç¤ºï¼æ¥æºäºå®¡æ¹å表æäº¤æ¨¡æ¿ï¼ */ |
| | | export function getBuiltinTemplates() { |
| | | return Object.entries(SUBMIT_TEMPLATES).map(([key, tpl]) => ({ |
| | | key, |
| | | approvalType: tpl.approvalType, |
| | | label: tpl.label, |
| | | summary: tpl.summaryPlaceholder || "ç³»ç»é¢ç½®å¡«æ¥å段", |
| | | fieldCount: (tpl.fields || []).length, |
| | | defaultMode: tpl.approvalMode, |
| | | })); |
| | | } |
| | | |
| | | export function nodeSignModeLabel(mode) { |
| | | return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.label || "â"; |
| | | } |
| | | |
| | | export function approvalTypeLabel(type) { |
| | | return APPROVAL_TYPE_OPTIONS.find((x) => x.value === type)?.label || type || "â"; |
| | | } |
| | | |
| | | export function createEmptyNode(order = 1) { |
| | | return { |
| | | nodeOrder: order, |
| | | signMode: "countersign", |
| | | approvers: [], |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyTemplateForm() { |
| | | return { |
| | | id: "", |
| | | templateName: "", |
| | | description: "", |
| | | enabled: true, |
| | | flowNodes: [createEmptyNode(1)], |
| | | }; |
| | | } |
| | | |
| | | export function normalizeFlowNodes(nodes) { |
| | | const list = Array.isArray(nodes) ? nodes : []; |
| | | return list.map((n, i) => ({ |
| | | nodeOrder: i + 1, |
| | | signMode: n.signMode === "or_sign" ? "or_sign" : "countersign", |
| | | approvers: (n.approvers || []) |
| | | .filter((a) => a?.approverId != null && a.approverId !== "") |
| | | .map((a) => ({ |
| | | approverId: a.approverId, |
| | | approverName: a.approverName || "", |
| | | })), |
| | | })); |
| | | } |
| | | |
| | | export function validateTemplateForm(form) { |
| | | const name = (form.templateName || "").trim(); |
| | | if (!name) return { ok: false, message: "è¯·å¡«åæ¨¡æ¿åç§°" }; |
| | | const nodes = normalizeFlowNodes(form.flowNodes); |
| | | if (!nodes.length) return { ok: false, message: "请è³å°é
ç½®ä¸ä¸ªå®¡æ¹èç¹" }; |
| | | for (let i = 0; i < nodes.length; i++) { |
| | | if (!nodes[i].approvers.length) { |
| | | return { ok: false, message: `请为第 ${i + 1} 个èç¹éæ©è³å°ä¸å审æ¹äºº` }; |
| | | } |
| | | } |
| | | return { ok: true, nodes, name }; |
| | | } |
| | | |
| | | export function flowNodesSummary(nodes) { |
| | | const list = normalizeFlowNodes(nodes); |
| | | if (!list.length) return "â"; |
| | | return list |
| | | .map((n, i) => { |
| | | const names = n.approvers.map((a) => a.approverName || "æªå½å").join("ã") || "æªé
ç½®"; |
| | | return `èç¹${i + 1}(${nodeSignModeLabel(n.signMode)}:${names})`; |
| | | }) |
| | | .join(" â "); |
| | | } |
| | | |
| | | export function createInitialMockTemplates() { |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | return [ |
| | | { |
| | | id: "tpl_demo_1", |
| | | templateName: "项ç®ç«é¡¹å®¡æ¹", |
| | | description: "è·¨é¨é¨é¡¹ç®ç«é¡¹ï¼éææ¯ãè´¢å¡ä¾æ¬¡ä¼ç¾", |
| | | enabled: true, |
| | | createTime: dayjs().subtract(5, "day").format("YYYY-MM-DD HH:mm:ss"), |
| | | updateTime: now, |
| | | flowNodes: [ |
| | | { |
| | | nodeOrder: 1, |
| | | signMode: "countersign", |
| | | approvers: [ |
| | | { approverId: "mock_tech_lead", approverName: "ææ¯è´è´£äºº" }, |
| | | { approverId: "mock_pm", approverName: "项ç®ç»ç" }, |
| | | ], |
| | | }, |
| | | { |
| | | nodeOrder: 2, |
| | | signMode: "or_sign", |
| | | approvers: [ |
| | | { approverId: "mock_finance", approverName: "è´¢å¡ä¸»ç®¡" }, |
| | | { approverId: "mock_cfo", approverName: "è´¢å¡æ»ç" }, |
| | | ], |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | id: "tpl_demo_2", |
| | | templateName: "ååç¨å°ç³è¯·", |
| | | description: "æ³å¡ä¸è¡æ¿æç¾åï¼æ»ç»çç»å®¡", |
| | | enabled: true, |
| | | createTime: dayjs().subtract(12, "day").format("YYYY-MM-DD HH:mm:ss"), |
| | | updateTime: dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss"), |
| | | flowNodes: [ |
| | | { |
| | | nodeOrder: 1, |
| | | signMode: "or_sign", |
| | | approvers: [ |
| | | { approverId: "mock_legal", approverName: "æ³å¡ä¸å" }, |
| | | { approverId: "mock_admin", approverName: "è¡æ¿ä¸»ç®¡" }, |
| | | ], |
| | | }, |
| | | { |
| | | nodeOrder: 2, |
| | | signMode: "countersign", |
| | | approvers: [{ approverId: "mock_ceo", approverName: "æ»ç»ç" }], |
| | | }, |
| | | ], |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | export function loadStoredTemplates() { |
| | | try { |
| | | const raw = localStorage.getItem(STORAGE_KEY); |
| | | if (!raw) return null; |
| | | const parsed = JSON.parse(raw); |
| | | return Array.isArray(parsed) ? parsed : null; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function saveStoredTemplates(rows) { |
| | | try { |
| | | localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); |
| | | } catch { |
| | | /* ignore */ |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- å®¡æ¹æ¨¡æ¿ï¼å¯é
ç½®èç¹æ°ï¼æ¯èç¹å¤äºº + ä¼ç¾/æç¾ --> |
| | | <template> |
| | | <div class="tfe"> |
| | | <div v-if="innerList.length" class="tfe-flow"> |
| | | <div v-for="(item, index) in innerList" :key="item._uid" class="tfe-flow-item"> |
| | | <div class="tfe-card" :class="{ 'tfe-card--empty': !item.approvers?.length }"> |
| | | <div class="tfe-badge">{{ index + 1 }}</div> |
| | | <div class="tfe-head"> |
| | | <span class="tfe-level">{{ levelText(index) }}</span> |
| | | <el-radio-group v-model="item.signMode" size="small" @change="emitOut"> |
| | | <el-radio-button value="countersign">ä¼ç¾</el-radio-button> |
| | | <el-radio-button value="or_sign">æç¾</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <p class="tfe-mode-tip">{{ signModeTip(item.signMode) }}</p> |
| | | <div class="tfe-select"> |
| | | <el-select |
| | | v-model="item.approverIds" |
| | | multiple |
| | | collapse-tags |
| | | collapse-tags-tooltip |
| | | :max-collapse-tags="2" |
| | | filterable |
| | | placeholder="è¯·éæ©å®¡æ¹äººï¼å¯å¤éï¼" |
| | | style="width: 100%" |
| | | @change="(ids) => onApproversChange(ids, item)" |
| | | > |
| | | <el-option |
| | | v-for="u in userOptions" |
| | | :key="String(u.userId ?? u.id)" |
| | | :label="optionLabel(u)" |
| | | :value="u.userId ?? u.id" |
| | | /> |
| | | </el-select> |
| | | </div> |
| | | <div v-if="item.approvers?.length" class="tfe-chips"> |
| | | <el-tag |
| | | v-for="a in item.approvers" |
| | | :key="String(a.approverId)" |
| | | size="small" |
| | | type="info" |
| | | effect="plain" |
| | | > |
| | | {{ a.approverName || "â" }} |
| | | </el-tag> |
| | | </div> |
| | | <div class="tfe-actions"> |
| | | <el-button type="primary" circle size="small" :disabled="index === 0" title="åç§»" @click="moveLeft(index)"> |
| | | <el-icon><ArrowLeft /></el-icon> |
| | | </el-button> |
| | | <el-button |
| | | type="primary" |
| | | circle |
| | | size="small" |
| | | :disabled="index === innerList.length - 1" |
| | | title="åç§»" |
| | | @click="moveRight(index)" |
| | | > |
| | | <el-icon><ArrowRight /></el-icon> |
| | | </el-button> |
| | | <el-button type="danger" circle size="small" title="å é¤èç¹" @click="remove(index)"> |
| | | <el-icon><Delete /></el-icon> |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | <div v-if="index < innerList.length - 1" class="tfe-conn"> |
| | | <div class="tfe-conn-line"></div> |
| | | <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="tfe-add-wrap"> |
| | | <div v-if="innerList.length" class="tfe-conn"> |
| | | <div class="tfe-conn-line"></div> |
| | | <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon> |
| | | </div> |
| | | <div class="tfe-add-card" @click="addNode"> |
| | | <div class="tfe-add-icon"><el-icon :size="26"><Plus /></el-icon></div> |
| | | <span>æ°å¢èç¹</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-else class="tfe-empty"> |
| | | <el-icon :size="44" color="#c0c4cc"><User /></el-icon> |
| | | <p>ææ å®¡æ¹èç¹</p> |
| | | <el-button type="primary" @click="addNode">æ·»å 第ä¸ä¸ªèç¹</el-button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue"; |
| | | import { ref, watch } from "vue"; |
| | | import { NODE_SIGN_MODE_OPTIONS, normalizeFlowNodes } from "../approveTemplateConstants.js"; |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { type: Array, default: () => [] }, |
| | | userOptions: { type: Array, default: () => [] }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:modelValue"]); |
| | | |
| | | const innerList = ref([]); |
| | | |
| | | function signModeTip(mode) { |
| | | return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.desc || ""; |
| | | } |
| | | |
| | | function levelText(i) { |
| | | const t = ["第ä¸çº§", "第äºçº§", "第ä¸çº§", "第å级", "第äºçº§", "第å
级", "第ä¸çº§", "第å
«çº§"]; |
| | | return t[i] || `第${i + 1}级`; |
| | | } |
| | | |
| | | function optionLabel(u) { |
| | | const nick = u.nickName || ""; |
| | | const un = u.userName || ""; |
| | | if (nick && un && nick !== un) return `${nick}ï¼${un}ï¼`; |
| | | return nick || un || `ç¨æ·${u.userId ?? u.id ?? ""}`; |
| | | } |
| | | |
| | | function newUid() { |
| | | return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; |
| | | } |
| | | |
| | | function mapIn(rows) { |
| | | const normalized = normalizeFlowNodes(rows); |
| | | return normalized.map((n) => ({ |
| | | _uid: newUid(), |
| | | nodeOrder: n.nodeOrder, |
| | | signMode: n.signMode, |
| | | approverIds: n.approvers.map((a) => a.approverId), |
| | | approvers: [...n.approvers], |
| | | })); |
| | | } |
| | | |
| | | function publicShape(rows) { |
| | | return normalizeFlowNodes( |
| | | (rows || []).map((r) => ({ |
| | | nodeOrder: r.nodeOrder, |
| | | signMode: r.signMode, |
| | | approvers: r.approvers || [], |
| | | })) |
| | | ); |
| | | } |
| | | |
| | | function emitOut() { |
| | | emit("update:modelValue", publicShape(innerList.value)); |
| | | } |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | (v) => { |
| | | const next = publicShape(v || []); |
| | | if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return; |
| | | innerList.value = mapIn(v || []); |
| | | }, |
| | | { deep: true, immediate: true } |
| | | ); |
| | | |
| | | function findUser(id) { |
| | | if (id == null || id === "") return null; |
| | | return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null; |
| | | } |
| | | |
| | | function onApproversChange(ids, row) { |
| | | const idList = Array.isArray(ids) ? ids : []; |
| | | row.approverIds = idList; |
| | | row.approvers = idList.map((id) => { |
| | | const u = findUser(id); |
| | | return { |
| | | approverId: id, |
| | | approverName: u ? u.nickName || u.userName || "" : "", |
| | | }; |
| | | }); |
| | | emitOut(); |
| | | } |
| | | |
| | | function addNode() { |
| | | innerList.value.push({ |
| | | _uid: newUid(), |
| | | nodeOrder: innerList.value.length + 1, |
| | | signMode: "countersign", |
| | | approverIds: [], |
| | | approvers: [], |
| | | }); |
| | | emitOut(); |
| | | } |
| | | |
| | | function remove(index) { |
| | | innerList.value.splice(index, 1); |
| | | emitOut(); |
| | | } |
| | | |
| | | function moveLeft(index) { |
| | | if (index < 1) return; |
| | | const t = innerList.value[index]; |
| | | innerList.value[index] = innerList.value[index - 1]; |
| | | innerList.value[index - 1] = t; |
| | | emitOut(); |
| | | } |
| | | |
| | | function moveRight(index) { |
| | | if (index >= innerList.value.length - 1) return; |
| | | const t = innerList.value[index]; |
| | | innerList.value[index] = innerList.value[index + 1]; |
| | | innerList.value[index + 1] = t; |
| | | emitOut(); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .tfe { |
| | | width: 100%; |
| | | } |
| | | .tfe-flow { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | flex-wrap: nowrap; |
| | | overflow-x: auto; |
| | | padding: 6px 0 10px; |
| | | } |
| | | .tfe-flow-item { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | .tfe-card { |
| | | width: 248px; |
| | | flex-shrink: 0; |
| | | border: 2px solid var(--el-border-color); |
| | | border-radius: 12px; |
| | | padding: 14px 12px 12px; |
| | | position: relative; |
| | | background: var(--el-bg-color); |
| | | } |
| | | .tfe-card--empty { |
| | | border-style: dashed; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .tfe-badge { |
| | | position: absolute; |
| | | top: -8px; |
| | | left: 12px; |
| | | width: 22px; |
| | | height: 22px; |
| | | border-radius: 50%; |
| | | background: var(--el-color-primary); |
| | | color: #fff; |
| | | font-size: 12px; |
| | | font-weight: 700; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | .tfe-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin: 8px 0 4px; |
| | | } |
| | | .tfe-level { |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: var(--el-text-color-primary); |
| | | } |
| | | .tfe-mode-tip { |
| | | font-size: 11px; |
| | | color: var(--el-text-color-secondary); |
| | | margin: 0 0 10px; |
| | | line-height: 1.4; |
| | | min-height: 30px; |
| | | } |
| | | .tfe-select { |
| | | margin-bottom: 8px; |
| | | } |
| | | .tfe-chips { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 4px; |
| | | margin-bottom: 8px; |
| | | min-height: 24px; |
| | | } |
| | | .tfe-actions { |
| | | display: flex; |
| | | justify-content: center; |
| | | gap: 8px; |
| | | padding-top: 10px; |
| | | border-top: 1px solid var(--el-border-color-lighter); |
| | | } |
| | | .tfe-conn { |
| | | display: flex; |
| | | align-items: center; |
| | | width: 40px; |
| | | flex-shrink: 0; |
| | | align-self: center; |
| | | } |
| | | .tfe-conn-line { |
| | | flex: 1; |
| | | height: 2px; |
| | | background: var(--el-border-color); |
| | | } |
| | | .tfe-conn-icon { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-placeholder); |
| | | margin-left: -2px; |
| | | } |
| | | .tfe-add-wrap { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | .tfe-add-card { |
| | | width: 120px; |
| | | min-height: 200px; |
| | | flex-shrink: 0; |
| | | border: 2px dashed var(--el-border-color); |
| | | border-radius: 12px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 10px; |
| | | cursor: pointer; |
| | | color: var(--el-text-color-regular); |
| | | font-size: 13px; |
| | | background: var(--el-fill-color-lighter); |
| | | transition: border-color 0.2s, background 0.2s; |
| | | } |
| | | .tfe-add-card:hover { |
| | | border-color: var(--el-color-primary); |
| | | background: var(--el-color-primary-light-9); |
| | | color: var(--el-color-primary); |
| | | } |
| | | .tfe-add-icon { |
| | | width: 44px; |
| | | height: 44px; |
| | | border-radius: 50%; |
| | | background: var(--el-color-primary); |
| | | color: #fff; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | .tfe-empty { |
| | | text-align: center; |
| | | padding: 28px 16px; |
| | | border: 1px dashed var(--el-border-color); |
| | | border-radius: 12px; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .tfe-empty p { |
| | | margin: 10px 0 14px; |
| | | color: var(--el-text-color-secondary); |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| | |
| | | <!-- |
| | | 模å䏿åï¼å®¡æ¹æ¨¡æ¿ |
| | | ç®å½æ è¯ï¼ApproveManage/approve-templateï¼approve-template â 䏿ï¼å®¡æ¹æ¨¡æ¿ï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <!--OA模åï¼å®¡æ¹æ¨¡æ¿ï¼ç³»ç»å¸¸ç¨ + èªå®ä¹å¤èç¹æµç¨ï¼--> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | <div class="app-container approve-template-page"> |
| | | <el-tabs v-model="activeTab" class="template-tabs"> |
| | | <el-tab-pane label="ç³»ç»å¸¸ç¨å®¡æ¹" name="builtin"> |
| | | <el-alert type="info" show-icon :closable="false" class="mb16"> |
| | | <template #title>ç³»ç»é¢ç½®æ¨¡æ¿</template> |
| | | <template #default> |
| | | 以ä¸ä¸º OA 模åå
ç½®ç常ç¨å®¡æ¹ç±»åï¼å¡«æ¥å段ä¸é»è®¤å®¡æ¹æ¹å¼ç±ç³»ç»ç»´æ¤ï¼æäº¤å®¡æ¹æ¶å¯ç´æ¥éç¨ã |
| | | </template> |
| | | </el-alert> |
| | | <div class="builtin-grid"> |
| | | <div v-for="item in builtinTemplates" :key="item.key" class="builtin-card"> |
| | | <span class="builtin-label">{{ item.label }}</span> |
| | | <p class="builtin-summary">{{ item.summary }}</p> |
| | | <div class="builtin-meta"> |
| | | <el-tag size="small" effect="plain">{{ item.fieldCount }} 个填æ¥é¡¹</el-tag> |
| | | <el-tag size="small" type="warning" effect="plain"> |
| | | é»è®¤{{ item.defaultMode === "or_sign" ? "æç¾" : "ä¸ç¾" }} |
| | | </el-tag> |
| | | <el-tag size="small" type="info" effect="plain">åªè¯»</el-tag> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-tab-pane> |
| | | |
| | | <el-tab-pane label="èªå®ä¹å®¡æ¹æ¨¡æ¿" name="custom"> |
| | | <div class="search_form mb20"> |
| | | <div class="search_fields"> |
| | | <span class="search_title">模æ¿åç§°ï¼</span> |
| | | <el-input |
| | | v-model="searchForm.keyword" |
| | | style="width: 220px" |
| | | placeholder="æç´¢åç§°æè¯´æ" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <el-checkbox v-model="searchForm.enabledOnly" class="ml12" @change="handleQuery"> |
| | | ä»
æ¾ç¤ºå¯ç¨ |
| | | </el-checkbox> |
| | | <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">æç´¢</el-button> |
| | | <el-button :icon="RefreshRight" @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div class="search_actions"> |
| | | <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">æ°å»ºæ¨¡æ¿</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | :total="page.total" |
| | | @pagination="pagination" |
| | | /> |
| | | </div> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | |
| | | <!-- æ°å»º / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="960px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="template-form-dialog" |
| | | @closed="formRef?.resetFields?.()" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="模æ¿åç§°" prop="templateName"> |
| | | <el-input v-model="form.templateName" placeholder="å¦ï¼é¡¹ç®ç«é¡¹å®¡æ¹" maxlength="50" show-word-limit /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å¯ç¨ç¶æ"> |
| | | <el-switch v-model="form.enabled" active-text="å¯ç¨" inactive-text="åç¨" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-form-item label="模æ¿è¯´æ"> |
| | | <el-input |
| | | v-model="form.description" |
| | | type="textarea" |
| | | :rows="2" |
| | | placeholder="ç®è¦è¯´æè¯¥æ¨¡æ¿çéç¨åºæ¯" |
| | | maxlength="200" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="å®¡æ¹æµç¨" required> |
| | | <TemplateFlowEditor v-model="form.flowNodes" :user-options="flowUserOptions" /> |
| | | <p class="flow-tip"> |
| | | æé¡ºåºæµè½¬ï¼å¯ä¸ºæ¯ä¸ªèç¹æ·»å å¤å审æ¹äººï¼ä¼ç¾éå
¨é¨éè¿ï¼æç¾ä»»ä¸äººéè¿å³å¯è¿å
¥ä¸ä¸èç¹ã |
| | | </p> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button type="primary" @click="onSubmitForm">ä¿ å</el-button> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="模æ¿è¯¦æ
" width="880px" append-to-body destroy-on-close> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="模æ¿åç§°">{{ detailRow.templateName }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç¶æ"> |
| | | <el-tag :type="detailRow.enabled !== false ? 'success' : 'info'" size="small"> |
| | | {{ detailRow.enabled !== false ? "å¯ç¨" : "åç¨" }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="说æ" :span="2">{{ detailRow.description || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´">{{ detailRow.createTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ´æ°æ¶é´">{{ detailRow.updateTime || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | <el-divider content-position="left">å®¡æ¹æµç¨ï¼{{ detailRow.flowNodes?.length || 0 }} 个èç¹ï¼</el-divider> |
| | | <div v-if="detailRow.flowNodes?.length" class="detail-flow"> |
| | | <div v-for="(node, index) in detailRow.flowNodes" :key="index" class="detail-node"> |
| | | <div class="detail-node-head"> |
| | | <span class="detail-node-order">èç¹ {{ index + 1 }}</span> |
| | | <el-tag size="small" :type="node.signMode === 'or_sign' ? 'warning' : 'primary'"> |
| | | {{ nodeSignModeLabel(node.signMode) }} |
| | | </el-tag> |
| | | </div> |
| | | <div class="detail-approvers"> |
| | | <el-tag |
| | | v-for="a in node.approvers" |
| | | :key="String(a.approverId)" |
| | | class="detail-approver-tag" |
| | | effect="plain" |
| | | > |
| | | {{ a.approverName || "â" }} |
| | | </el-tag> |
| | | <span v-if="!node.approvers?.length" class="text-muted">æªé
置审æ¹äºº</span> |
| | | </div> |
| | | <el-icon v-if="index < detailRow.flowNodes.length - 1" class="detail-arrow"><ArrowRight /></el-icon> |
| | | </div> |
| | | </div> |
| | | <el-empty v-else description="ææ æµç¨èç¹" :image-size="60" /> |
| | | <template #footer> |
| | | <el-button @click="detailDialog.visible = false">å
³ é</el-button> |
| | | <el-button type="primary" @click="editFromDetail">ç¼ è¾</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue' |
| | | import { ArrowRight, Document, Plus, RefreshRight } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import { onMounted, ref } from "vue"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import TemplateFlowEditor from "./components/TemplateFlowEditor.vue"; |
| | | import { useApproveTemplate } from "./useApproveTemplate.js"; |
| | | |
| | | const at = useApproveTemplate(); |
| | | const { |
| | | Search, |
| | | activeTab, |
| | | builtinTemplates, |
| | | nodeSignModeLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | formDialog, |
| | | form, |
| | | formRef, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openFormDialog, |
| | | openDetail, |
| | | submitForm, |
| | | } = at; |
| | | |
| | | const flowUserOptions = ref([]); |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload?.data && Array.isArray(payload.data)) return payload.data; |
| | | if (payload?.rows && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | async function loadUsers() { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | flowUserOptions.value = unwrapArray(res).filter(isActiveUser); |
| | | } catch { |
| | | flowUserOptions.value = []; |
| | | } |
| | | } |
| | | |
| | | async function onSubmitForm() { |
| | | const ret = await submitForm(); |
| | | if (ret?.message) { |
| | | ElMessage.warning(ret.message); |
| | | return; |
| | | } |
| | | if (ret?.ok) ElMessage.success("ä¿åæå"); |
| | | } |
| | | |
| | | function editFromDetail() { |
| | | const row = detailRow.value; |
| | | detailDialog.visible = false; |
| | | openFormDialog("edit", row); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadUsers(); |
| | | handleQuery(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .mb16 { |
| | | margin-bottom: 16px; |
| | | } |
| | | .ml10 { |
| | | margin-left: 10px; |
| | | } |
| | | .ml12 { |
| | | margin-left: 12px; |
| | | } |
| | | .page-header .header-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: var(--el-text-color-primary); |
| | | margin-bottom: 8px; |
| | | } |
| | | .title-icon { |
| | | font-size: 22px; |
| | | color: var(--el-color-primary); |
| | | } |
| | | .header-desc { |
| | | margin: 0; |
| | | font-size: 13px; |
| | | color: var(--el-text-color-secondary); |
| | | line-height: 1.6; |
| | | max-width: 920px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_fields { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | .search_actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | } |
| | | .builtin-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
| | | gap: 12px; |
| | | } |
| | | .builtin-card { |
| | | padding: 14px 16px; |
| | | border: 1px solid var(--el-border-color-lighter); |
| | | border-radius: var(--radius-md, 8px); |
| | | background: var(--el-fill-color-blank); |
| | | } |
| | | .builtin-label { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: var(--el-text-color-primary); |
| | | } |
| | | .builtin-summary { |
| | | margin: 8px 0 10px; |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | line-height: 1.5; |
| | | min-height: 36px; |
| | | } |
| | | .builtin-meta { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 6px; |
| | | } |
| | | .flow-tip { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | margin: 8px 0 0; |
| | | line-height: 1.5; |
| | | } |
| | | .detail-flow { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-start; |
| | | gap: 8px; |
| | | } |
| | | .detail-node { |
| | | position: relative; |
| | | min-width: 180px; |
| | | max-width: 240px; |
| | | padding: 12px; |
| | | border: 1px solid var(--el-border-color-lighter); |
| | | border-radius: 8px; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .detail-node-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 8px; |
| | | } |
| | | .detail-node-order { |
| | | font-weight: 600; |
| | | font-size: 13px; |
| | | } |
| | | .detail-approvers { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 4px; |
| | | } |
| | | .detail-approver-tag { |
| | | margin: 0; |
| | | } |
| | | .detail-arrow { |
| | | position: absolute; |
| | | right: -20px; |
| | | top: 50%; |
| | | transform: translateY(-50%); |
| | | color: var(--el-text-color-placeholder); |
| | | } |
| | | .text-muted { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-placeholder); |
| | | } |
| | | .template-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 8px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import { ElMessageBox } from "element-plus"; |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { |
| | | createEmptyTemplateForm, |
| | | createInitialMockTemplates, |
| | | flowNodesSummary, |
| | | getBuiltinTemplates, |
| | | loadStoredTemplates, |
| | | nodeSignModeLabel, |
| | | saveStoredTemplates, |
| | | validateTemplateForm, |
| | | } from "./approveTemplateConstants.js"; |
| | | |
| | | export function useApproveTemplate() { |
| | | const stored = loadStoredTemplates(); |
| | | const allTemplates = ref(stored?.length ? stored : createInitialMockTemplates()); |
| | | |
| | | const activeTab = ref("custom"); |
| | | const builtinTemplates = getBuiltinTemplates(); |
| | | |
| | | const searchForm = reactive({ |
| | | keyword: "", |
| | | enabledOnly: false, |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | |
| | | const formDialog = reactive({ visible: false, title: "", mode: "add" }); |
| | | const form = reactive(createEmptyTemplateForm()); |
| | | const formRef = ref(); |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allTemplates.value]; |
| | | const kw = (searchForm.keyword || "").trim().toLowerCase(); |
| | | if (kw) { |
| | | list = list.filter((r) => { |
| | | const name = (r.templateName || "").toLowerCase(); |
| | | const desc = (r.description || "").toLowerCase(); |
| | | return name.includes(kw) || desc.includes(kw); |
| | | }); |
| | | } |
| | | if (searchForm.enabledOnly) { |
| | | list = list.filter((r) => r.enabled !== false); |
| | | } |
| | | return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1)); |
| | | }); |
| | | |
| | | watch( |
| | | filteredList, |
| | | (list) => { |
| | | page.total = list.length; |
| | | const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); |
| | | if (page.current > maxPage) page.current = maxPage; |
| | | }, |
| | | { immediate: true } |
| | | ); |
| | | |
| | | const tableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return filteredList.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const formRules = { |
| | | templateName: [{ required: true, message: "请è¾å
¥æ¨¡æ¿åç§°", trigger: "blur" }], |
| | | }; |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "模æ¿åç§°", prop: "templateName", minWidth: 140 }, |
| | | { label: "说æ", prop: "description", minWidth: 160, showOverflowTooltip: true }, |
| | | { |
| | | label: "èç¹æ°", |
| | | prop: "flowNodes", |
| | | width: 80, |
| | | align: "center", |
| | | formatData: (v) => (Array.isArray(v) ? v.length : 0), |
| | | }, |
| | | { |
| | | label: "æµç¨æ¦è¦", |
| | | prop: "flowNodes", |
| | | minWidth: 220, |
| | | showOverflowTooltip: true, |
| | | formatData: (v) => flowNodesSummary(v), |
| | | }, |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "enabled", |
| | | width: 90, |
| | | align: "center", |
| | | dataType: "tag", |
| | | formatData: (v) => (v !== false ? "å¯ç¨" : "åç¨"), |
| | | formatType: (v) => (v !== false ? "success" : "info"), |
| | | }, |
| | | { label: "æ´æ°æ¶é´", prop: "updateTime", width: 170 }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 200, |
| | | operation: [ |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { name: "ç¼è¾", type: "text", clickFun: (row) => openFormDialog("edit", row) }, |
| | | { name: "å é¤", type: "text", clickFun: (row) => removeTemplate(row) }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | function persist() { |
| | | saveStoredTemplates(allTemplates.value); |
| | | } |
| | | |
| | | function handleQuery() { |
| | | tableLoading.value = true; |
| | | page.current = 1; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.keyword = ""; |
| | | searchForm.enabledOnly = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination({ page: p, limit }) { |
| | | page.current = p; |
| | | page.size = limit; |
| | | } |
| | | |
| | | function resetForm(row) { |
| | | const base = createEmptyTemplateForm(); |
| | | if (!row) { |
| | | Object.assign(form, base); |
| | | return; |
| | | } |
| | | Object.assign(form, { |
| | | ...base, |
| | | id: row.id, |
| | | templateName: row.templateName || "", |
| | | description: row.description || "", |
| | | enabled: row.enabled !== false, |
| | | flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])), |
| | | }); |
| | | } |
| | | |
| | | function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.title = mode === "add" ? "æ°å»ºèªå®ä¹å®¡æ¹æ¨¡æ¿" : "ç¼è¾èªå®ä¹å®¡æ¹æ¨¡æ¿"; |
| | | resetForm(mode === "edit" ? row : null); |
| | | formDialog.visible = true; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function isNameDuplicate(name, excludeId) { |
| | | const n = (name || "").trim(); |
| | | return allTemplates.value.some((t) => t.templateName?.trim() === n && t.id !== excludeId); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | if (!formRef.value) return false; |
| | | try { |
| | | await formRef.value.validate(); |
| | | } catch { |
| | | return false; |
| | | } |
| | | const validated = validateTemplateForm(form); |
| | | if (!validated.ok) { |
| | | return { message: validated.message }; |
| | | } |
| | | if (isNameDuplicate(validated.name, form.id)) { |
| | | return { message: "模æ¿åç§°å·²åå¨ï¼è¯·æ´æ¢åç§°" }; |
| | | } |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | if (formDialog.mode === "add") { |
| | | allTemplates.value.unshift({ |
| | | id: `tpl_${Date.now()}`, |
| | | templateName: validated.name, |
| | | description: (form.description || "").trim(), |
| | | enabled: form.enabled !== false, |
| | | createTime: now, |
| | | updateTime: now, |
| | | flowNodes: validated.nodes, |
| | | }); |
| | | } else { |
| | | const hit = allTemplates.value.find((t) => t.id === form.id); |
| | | if (!hit) return { message: "模æ¿ä¸å卿已å é¤" }; |
| | | hit.templateName = validated.name; |
| | | hit.description = (form.description || "").trim(); |
| | | hit.enabled = form.enabled !== false; |
| | | hit.flowNodes = validated.nodes; |
| | | hit.updateTime = now; |
| | | } |
| | | persist(); |
| | | formDialog.visible = false; |
| | | page.current = 1; |
| | | return { ok: true }; |
| | | } |
| | | |
| | | async function removeTemplate(row) { |
| | | try { |
| | | await ElMessageBox.confirm(`ç¡®å®å 餿¨¡æ¿ã${row.templateName}ãåï¼`, "æç¤º", { |
| | | type: "warning", |
| | | confirmButtonText: "å é¤", |
| | | cancelButtonText: "åæ¶", |
| | | }); |
| | | } catch { |
| | | return; |
| | | } |
| | | const idx = allTemplates.value.findIndex((t) => t.id === row.id); |
| | | if (idx >= 0) { |
| | | allTemplates.value.splice(idx, 1); |
| | | persist(); |
| | | } |
| | | } |
| | | |
| | | function toggleEnabled(row) { |
| | | const hit = allTemplates.value.find((t) => t.id === row.id); |
| | | if (!hit) return; |
| | | hit.enabled = !hit.enabled; |
| | | hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | persist(); |
| | | } |
| | | |
| | | return { |
| | | Search, |
| | | activeTab, |
| | | builtinTemplates, |
| | | nodeSignModeLabel, |
| | | flowNodesSummary, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | formDialog, |
| | | form, |
| | | formRef, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openFormDialog, |
| | | openDetail, |
| | | submitForm, |
| | | toggleEnabled, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- EnterpriseNewsï¼è¯¦æ
åªè¯»é¢æ¿ï¼å«äºå¨ï¼ --> |
| | | <template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="æ°é»ç¼å·">{{ row.newsNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå¸ç¶æ"> |
| | | <el-tag :type="publishStatusTag(row.publishStatus)" size="small"> |
| | | {{ publishStatusLabel(row.publishStatus) }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="æ°é»åç±»"> |
| | | <span class="type-badge" :style="{ color: newsTypeColor(row.newsType) }"> |
| | | {{ newsTypeLabel(row.newsType) }} |
| | | </span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="æç模æ¿">{{ layoutTemplateLabel(row.layoutTemplate) }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ é¢" :span="2">{{ row.title || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æè¦" :span="2">{{ row.summary || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="é
读èå´">{{ readScopeLabel(row.readScope) }}</el-descriptions-item> |
| | | <el-descriptions-item label="é
读ç"> |
| | | {{ readRate(row) }}%ï¼æªè¯» {{ unreadCount }} äººï¼ |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="ç¼è¾æé">{{ publishRoleLabel(row.editorRole) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ ¸è§è²">{{ publishRoleLabel(row.reviewerRole) }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå¸äºº">{{ row.publisherName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å叿¶é´">{{ row.publishTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å½åçæ¬">v{{ row.versionNo || 1 }}</el-descriptions-item> |
| | | <el-descriptions-item label="éé
读确认"> |
| | | {{ row.requireReadConfirm ? "æ¯" : "å¦" }} |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-divider content-position="left">æ£æå
容</el-divider> |
| | | <div v-if="row.contentHtml" class="news-html-body" v-html="row.contentHtml" /> |
| | | <el-empty v-else description="ææ æ£æ" :image-size="48" /> |
| | | |
| | | <template v-if="row.mediaList?.length"> |
| | | <el-divider content-position="left">å¾é / è§é¢</el-divider> |
| | | <div class="media-grid"> |
| | | <div v-for="(m, i) in row.mediaList" :key="i" class="media-item"> |
| | | <el-tag size="small" type="info">{{ m.type === "video" ? "è§é¢" : "å¾ç" }}</el-tag> |
| | | <span class="media-name">{{ m.name }}</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <el-divider content-position="left">éä»¶</el-divider> |
| | | <template v-if="row.attachmentList?.length"> |
| | | <el-tag |
| | | v-for="(f, i) in row.attachmentList" |
| | | :key="i" |
| | | class="file-tag" |
| | | type="info" |
| | | @click="openFile(f)" |
| | | > |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <el-empty v-else description="ææ éä»¶" :image-size="48" /> |
| | | |
| | | <template v-if="row.newsType === 'culture' && row.publishStatus === 'published'"> |
| | | <el-divider content-position="left">äºå¨ï¼ç¹èµ {{ likeCount }} · è¯è®º {{ commentCount }}ï¼</el-divider> |
| | | <div class="interaction-bar"> |
| | | <el-button type="primary" plain size="small" @click="$emit('like')"> |
| | | {{ likedByMe ? "åæ¶ç¹èµ" : "ç¹èµ" }} |
| | | </el-button> |
| | | </div> |
| | | <el-input |
| | | v-model="commentDraft" |
| | | type="textarea" |
| | | :rows="2" |
| | | maxlength="300" |
| | | show-word-limit |
| | | placeholder="åä¸ä½ çè¯è®ºâ¦" |
| | | class="mb8" |
| | | /> |
| | | <el-button type="primary" size="small" @click="submitComment">å表è¯è®º</el-button> |
| | | <el-timeline v-if="row.comments?.length" class="comment-timeline mt12"> |
| | | <el-timeline-item v-for="c in row.comments" :key="c.id" :timestamp="c.time"> |
| | | <strong>{{ c.name }}</strong>ï¼{{ c.content }} |
| | | </el-timeline-item> |
| | | </el-timeline> |
| | | <el-empty v-else description="ææ è¯è®º" :image-size="40" /> |
| | | </template> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref } from "vue"; |
| | | import { |
| | | newsTypeLabel, |
| | | newsTypeColor, |
| | | publishStatusLabel, |
| | | publishStatusTag, |
| | | layoutTemplateLabel, |
| | | readScopeLabel, |
| | | publishRoleLabel, |
| | | readRate, |
| | | getUnreadEmployees, |
| | | } from "../enterpriseNewsUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["like", "comment"]); |
| | | |
| | | const commentDraft = ref(""); |
| | | |
| | | const unreadCount = computed(() => getUnreadEmployees(props.row).length); |
| | | const likeCount = computed(() => props.row?.likes?.length || 0); |
| | | const commentCount = computed(() => props.row?.comments?.length || 0); |
| | | const likedByMe = computed(() => (props.row?.likes || []).some((l) => l.userId === "u1")); |
| | | |
| | | function openFile(f) { |
| | | const url = f?.url || f?.downloadURL; |
| | | if (url) window.open(url, "_blank"); |
| | | } |
| | | |
| | | function submitComment() { |
| | | emit("comment", commentDraft.value); |
| | | commentDraft.value = ""; |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .type-badge { |
| | | font-weight: 600; |
| | | } |
| | | .news-html-body { |
| | | padding: 12px 16px; |
| | | background: var(--el-fill-color-light); |
| | | border-radius: 6px; |
| | | line-height: 1.7; |
| | | max-height: 320px; |
| | | overflow-y: auto; |
| | | } |
| | | .media-grid { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 12px; |
| | | } |
| | | .media-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 8px 12px; |
| | | background: var(--el-fill-color-lighter); |
| | | border-radius: 4px; |
| | | } |
| | | .media-name { |
| | | font-size: 13px; |
| | | } |
| | | .file-tag { |
| | | margin: 0 8px 8px 0; |
| | | cursor: pointer; |
| | | } |
| | | .interaction-bar { |
| | | margin-bottom: 8px; |
| | | } |
| | | .comment-timeline { |
| | | max-height: 200px; |
| | | overflow-y: auto; |
| | | } |
| | | .mb8 { |
| | | margin-bottom: 8px; |
| | | } |
| | | .mt12 { |
| | | margin-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | /** æ°é»åç±»ï¼ç»ä¸ä¿¡æ¯åºå£ */ |
| | | export const NEWS_TYPE_OPTIONS = [ |
| | | { value: "announcement", label: "ä¼ä¸å
Œ", color: "#409eff" }, |
| | | { value: "policy", label: "æ¿ç解读", color: "#e6a23c" }, |
| | | { value: "industry", label: "è¡ä¸å¨æ", color: "#909399" }, |
| | | { value: "culture", label: "æåæ´»å¨", color: "#67c23a" }, |
| | | ]; |
| | | |
| | | /** åå¸ç¶æ */ |
| | | export const PUBLISH_STATUS_OPTIONS = [ |
| | | { value: "draft", label: "è稿", tag: "info" }, |
| | | { value: "pending_review", label: "å¾
å®¡æ ¸", tag: "warning" }, |
| | | { value: "published", label: "å·²åå¸", tag: "success" }, |
| | | { value: "archived", label: "已彿¡£", tag: "" }, |
| | | ]; |
| | | |
| | | /** æçæ¨¡æ¿ */ |
| | | export const LAYOUT_TEMPLATE_OPTIONS = [ |
| | | { value: "standard", label: "æ å徿" }, |
| | | { value: "policy", label: "æ¿çæ¡æ" }, |
| | | { value: "gallery", label: "å¾éç¸å" }, |
| | | { value: "briefing", label: "ç®æ¥æè¦" }, |
| | | ]; |
| | | |
| | | /** é
读å¯è§èå´ */ |
| | | export const READ_SCOPE_OPTIONS = [ |
| | | { value: "all", label: "å
¨åå¯è§" }, |
| | | { value: "management", label: "管çå±" }, |
| | | { value: "department", label: "æå®é¨é¨" }, |
| | | { value: "custom", label: "èªå®ä¹åå" }, |
| | | ]; |
| | | |
| | | /** ç¼è¾/å®¡æ ¸è§è²ï¼å叿éï¼ */ |
| | | export const PUBLISH_ROLE_OPTIONS = [ |
| | | { value: "hr", label: "HRï¼äººäºæ¿çï¼" }, |
| | | { value: "admin", label: "管çåï¼å¤é¨æ°é»å®¡æ ¸ï¼" }, |
| | | { value: "dept_manager", label: "é¨é¨è´è´£äºº" }, |
| | | { value: "editor", label: "å
容ç¼è¾" }, |
| | | ]; |
| | | |
| | | export const STORAGE_KEY = "oa_enterprise_news_v1"; |
| | | |
| | | /** æ¼ç¤ºç¨ç®æ åä¼ï¼åæå¯¹æ¥ç»ç»æ¶æï¼ */ |
| | | export const MOCK_AUDIENCE = [ |
| | | { userId: "u1", employeeNo: "zhangsan", name: "å¼ ä¸", deptName: "ç åé¨", isManagement: false }, |
| | | { userId: "u2", employeeNo: "lisi", name: "æå", deptName: "ç åé¨", isManagement: false }, |
| | | { userId: "u3", employeeNo: "wangwu", name: "çäº", deptName: "è¡æ¿é¨", isManagement: false }, |
| | | { userId: "u4", employeeNo: "zhaoliu", name: "èµµå
", deptName: "éå®é¨", isManagement: false }, |
| | | { userId: "u5", employeeNo: "sunqi", name: "åä¸", deptName: "è´¢å¡é¨", isManagement: false }, |
| | | { userId: "u6", employeeNo: "zhouba", name: "å¨å
«", deptName: "æ»ç»å", isManagement: true }, |
| | | { userId: "u7", employeeNo: "wujiu", name: "å´ä¹", deptName: "æ»ç»å", isManagement: true }, |
| | | { userId: "u8", employeeNo: "zhengshi", name: "éå", deptName: "人åèµæºé¨", isManagement: false }, |
| | | ]; |
| | | |
| | | const DEPT_OPTIONS = [ |
| | | { value: "101", label: "ç åé¨" }, |
| | | { value: "102", label: "éå®é¨" }, |
| | | { value: "103", label: "è¡æ¿é¨" }, |
| | | { value: "104", label: "è´¢å¡é¨" }, |
| | | { value: "105", label: "æ»ç»å" }, |
| | | { value: "106", label: "人åèµæºé¨" }, |
| | | ]; |
| | | |
| | | export { DEPT_OPTIONS }; |
| | | |
| | | export function newsTypeLabel(v) { |
| | | return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function newsTypeColor(v) { |
| | | return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399"; |
| | | } |
| | | |
| | | export function publishStatusLabel(v) { |
| | | return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function publishStatusTag(v) { |
| | | return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info"; |
| | | } |
| | | |
| | | export function layoutTemplateLabel(v) { |
| | | return LAYOUT_TEMPLATE_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function readScopeLabel(v) { |
| | | return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function publishRoleLabel(v) { |
| | | return PUBLISH_ROLE_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function createEmptyForm() { |
| | | return { |
| | | id: "", |
| | | newsNo: "", |
| | | title: "", |
| | | summary: "", |
| | | newsType: "announcement", |
| | | layoutTemplate: "standard", |
| | | contentHtml: "", |
| | | coverImage: "", |
| | | mediaList: [], |
| | | attachmentList: [], |
| | | editorRole: "hr", |
| | | reviewerRole: "admin", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | targetUserIds: [], |
| | | publishStatus: "draft", |
| | | publisherName: "", |
| | | publishTime: "", |
| | | readRecords: [], |
| | | remindLogs: [], |
| | | likes: [], |
| | | comments: [], |
| | | versions: [], |
| | | versionNo: 1, |
| | | requireReadConfirm: false, |
| | | }; |
| | | } |
| | | |
| | | function buildReadRecords(readUserIds = []) { |
| | | const set = new Set(readUserIds); |
| | | return MOCK_AUDIENCE.map((u) => ({ |
| | | userId: u.userId, |
| | | employeeNo: u.employeeNo, |
| | | name: u.name, |
| | | deptName: u.deptName, |
| | | readAt: set.has(u.userId) ? dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss") : "", |
| | | lastRemindAt: "", |
| | | })); |
| | | } |
| | | |
| | | function createVersionSnapshot(row, changeNote = "åå¸") { |
| | | return { |
| | | versionNo: row.versionNo || 1, |
| | | title: row.title, |
| | | summary: row.summary, |
| | | contentHtml: row.contentHtml, |
| | | newsType: row.newsType, |
| | | publishTime: row.publishTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | archivedAt: dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | changeNote, |
| | | publisherName: row.publisherName || "ç³»ç»", |
| | | }; |
| | | } |
| | | |
| | | export function createInitialMockNews() { |
| | | const policyContent = |
| | | "<p><strong>2026 å¹´èå¤ç®¡çå¶åº¦ï¼è¯è¡ï¼</strong></p><p>ä¸ãä¸çæ¶é´ 9:00ï¼å¼¹æ§æå¡çªå£ 8:30â9:30ã</p><p>äºã请åé¡»æåå¨ OA æäº¤å®¡æ¹ã</p><p>ä¸ãæ¬å¶åº¦èª 2026-06-01 èµ·æ§è¡ã</p>"; |
| | | const cultureContent = |
| | | "<p>2026 ä¼ä¸å¹´ä¼å满è½å¹ï¼æè°¢æ¯ä¸ä½åäºçåä¸ï¼ä»¥ä¸ä¸ºç²¾å½©ç¬é´å¾éã</p>"; |
| | | const strategyContent = |
| | | "<p><strong>2026 ä¸åå¹´æç¥æ¹åï¼å
é¨ï¼</strong></p><p>èç¦æ ¸å¿äº§å线åçº§ä¸æµ·å¤å¸åºæå±ï¼å
·ä½ææ è§éä»¶ã</p>"; |
| | | |
| | | const policyRow = { |
| | | id: "news_1", |
| | | newsNo: "EN202605150001", |
| | | title: "å
³äºå叿°èå¤å¶åº¦çéç¥", |
| | | summary: "请å
¨ä½å工认çé
è¯»å¹¶ç¡®è®¤ç¥æï¼èª 2026-06-01 èµ·æ§è¡ã", |
| | | newsType: "policy", |
| | | layoutTemplate: "policy", |
| | | contentHtml: policyContent, |
| | | coverImage: "", |
| | | mediaList: [], |
| | | attachmentList: [{ name: "èå¤å¶åº¦2026.pdf", url: "/mock/attendance-policy.pdf" }], |
| | | editorRole: "hr", |
| | | reviewerRole: "admin", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | targetUserIds: [], |
| | | publishStatus: "published", |
| | | publisherName: "人åèµæºé¨", |
| | | publishTime: "2026-05-15 10:00:00", |
| | | readRecords: buildReadRecords(["u6", "u7", "u8"]), |
| | | remindLogs: [], |
| | | likes: [], |
| | | comments: [], |
| | | versions: [ |
| | | { |
| | | versionNo: 1, |
| | | title: "å
³äºå叿°èå¤å¶åº¦çéç¥ï¼å¾æ±æè§ç¨¿ï¼", |
| | | summary: "徿±æè§ç¨¿", |
| | | contentHtml: "<p>徿±æè§ç¨¿ï¼ä¸çæ¶é´ 9:00â¦â¦</p>", |
| | | newsType: "policy", |
| | | publishTime: "2026-05-10 09:00:00", |
| | | archivedAt: "2026-05-15 09:55:00", |
| | | changeNote: "å®ç¨¿åå¸", |
| | | publisherName: "人åèµæºé¨", |
| | | }, |
| | | ], |
| | | versionNo: 2, |
| | | requireReadConfirm: true, |
| | | createTime: "2026-05-10 09:00:00", |
| | | updateTime: "2026-05-15 10:00:00", |
| | | }; |
| | | |
| | | const cultureRow = { |
| | | id: "news_2", |
| | | newsNo: "EN202605200002", |
| | | title: "2026 ä¼ä¸å¹´ä¼ç²¾å½©ç¬é´", |
| | | summary: "å¹´ä¼å¾éä¸çº¿ï¼æ¬¢è¿ç¹èµçè¨ï¼å
±å»ºä¼ä¸æåã", |
| | | newsType: "culture", |
| | | layoutTemplate: "gallery", |
| | | contentHtml: cultureContent, |
| | | coverImage: "/mock/annual-cover.jpg", |
| | | mediaList: [ |
| | | { type: "image", name: "å¼åº.jpg", url: "/mock/annual-1.jpg" }, |
| | | { type: "image", name: "é¢å¥.jpg", url: "/mock/annual-2.jpg" }, |
| | | { type: "video", name: "å¹´ä¼è±çµ®.mp4", url: "/mock/annual.mp4" }, |
| | | ], |
| | | attachmentList: [], |
| | | editorRole: "dept_manager", |
| | | reviewerRole: "admin", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | targetUserIds: [], |
| | | publishStatus: "published", |
| | | publisherName: "è¡æ¿é¨", |
| | | publishTime: "2026-05-20 14:30:00", |
| | | readRecords: buildReadRecords(["u1", "u2", "u3", "u4", "u5", "u6", "u7"]), |
| | | remindLogs: [], |
| | | likes: [ |
| | | { userId: "u1", name: "å¼ ä¸", time: "2026-05-20 15:01:00" }, |
| | | { userId: "u2", name: "æå", time: "2026-05-20 15:05:00" }, |
| | | { userId: "u4", name: "èµµå
", time: "2026-05-20 16:20:00" }, |
| | | ], |
| | | comments: [ |
| | | { id: "c1", userId: "u1", name: "å¼ ä¸", content: "èç®å¤ªç²¾å½©äºï¼", time: "2026-05-20 15:10:00" }, |
| | | { id: "c2", userId: "u3", name: "çäº", content: "æå¾
æå¹´åèï¼", time: "2026-05-20 17:00:00" }, |
| | | ], |
| | | versions: [], |
| | | versionNo: 1, |
| | | requireReadConfirm: false, |
| | | createTime: "2026-05-20 14:00:00", |
| | | updateTime: "2026-05-20 14:30:00", |
| | | }; |
| | | |
| | | const strategyRow = { |
| | | id: "news_3", |
| | | newsNo: "EN202605220003", |
| | | title: "2026 ä¸åå¹´æç¥è§åè¦ç¹", |
| | | summary: "ä»
é管çå±é
读ï¼è¯·å¿å¯¹å¤ä¼ æã", |
| | | newsType: "announcement", |
| | | layoutTemplate: "briefing", |
| | | contentHtml: strategyContent, |
| | | coverImage: "", |
| | | mediaList: [], |
| | | attachmentList: [{ name: "æç¥ææ .pdf", url: "/mock/strategy.pdf" }], |
| | | editorRole: "admin", |
| | | reviewerRole: "admin", |
| | | readScope: "management", |
| | | targetDeptIds: [], |
| | | targetUserIds: [], |
| | | publishStatus: "published", |
| | | publisherName: "æ»ç»å", |
| | | publishTime: "2026-05-22 09:00:00", |
| | | readRecords: buildReadRecords(["u6", "u7"]), |
| | | remindLogs: [], |
| | | likes: [], |
| | | comments: [], |
| | | versions: [], |
| | | versionNo: 1, |
| | | requireReadConfirm: false, |
| | | createTime: "2026-05-22 08:30:00", |
| | | updateTime: "2026-05-22 09:00:00", |
| | | }; |
| | | |
| | | const industryDraft = { |
| | | id: "news_4", |
| | | newsNo: "EN202605250004", |
| | | title: "å¶é 䏿°åå转åè¶å¿ç®æ¥", |
| | | summary: "è¡ä¸å¨æè稿ï¼å¾
管çåå®¡æ ¸ååå¸ã", |
| | | newsType: "industry", |
| | | layoutTemplate: "standard", |
| | | contentHtml: "<p>æ¬æç®æ¥æ¢³çå·¥ä¸äºèç½ä¸ AI è´¨æ£åºç¨æ¡ä¾â¦â¦</p>", |
| | | coverImage: "", |
| | | mediaList: [], |
| | | attachmentList: [], |
| | | editorRole: "editor", |
| | | reviewerRole: "admin", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | targetUserIds: [], |
| | | publishStatus: "pending_review", |
| | | publisherName: "å¸åºé¨", |
| | | publishTime: "", |
| | | readRecords: [], |
| | | remindLogs: [], |
| | | likes: [], |
| | | comments: [], |
| | | versions: [], |
| | | versionNo: 1, |
| | | requireReadConfirm: false, |
| | | createTime: "2026-05-25 11:00:00", |
| | | updateTime: "2026-05-25 11:00:00", |
| | | }; |
| | | |
| | | return [policyRow, cultureRow, strategyRow, industryDraft]; |
| | | } |
| | | |
| | | export function loadStoredNews() { |
| | | try { |
| | | const raw = localStorage.getItem(STORAGE_KEY); |
| | | if (!raw) return null; |
| | | const data = JSON.parse(raw); |
| | | return Array.isArray(data) ? data : null; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function saveStoredNews(rows) { |
| | | try { |
| | | localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); |
| | | } catch { |
| | | /* ignore */ |
| | | } |
| | | } |
| | | |
| | | /** æé
读èå´è§£æç®æ åä¼ */ |
| | | export function resolveTargetAudience(row) { |
| | | const scope = row.readScope || "all"; |
| | | if (scope === "management") { |
| | | return MOCK_AUDIENCE.filter((u) => u.isManagement); |
| | | } |
| | | if (scope === "department" && row.targetDeptIds?.length) { |
| | | const names = DEPT_OPTIONS.filter((d) => row.targetDeptIds.includes(d.value)).map((d) => d.label); |
| | | return MOCK_AUDIENCE.filter((u) => names.includes(u.deptName)); |
| | | } |
| | | if (scope === "custom" && row.targetUserIds?.length) { |
| | | return MOCK_AUDIENCE.filter((u) => row.targetUserIds.includes(u.userId)); |
| | | } |
| | | return [...MOCK_AUDIENCE]; |
| | | } |
| | | |
| | | export function getUnreadEmployees(row) { |
| | | const audience = resolveTargetAudience(row); |
| | | const readSet = new Set( |
| | | (row.readRecords || []).filter((r) => r.readAt).map((r) => r.userId) |
| | | ); |
| | | return audience.filter((u) => !readSet.has(u.userId)); |
| | | } |
| | | |
| | | export function readRate(row) { |
| | | const audience = resolveTargetAudience(row); |
| | | if (!audience.length) return 0; |
| | | const readCount = (row.readRecords || []).filter((r) => r.readAt).length; |
| | | return Math.round((readCount / audience.length) * 100); |
| | | } |
| | | |
| | | export function nextNewsNo() { |
| | | return `EN${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`; |
| | | } |
| | | |
| | | export function pushVersionBeforeUpdate(row, changeNote) { |
| | | const versions = row.versions || []; |
| | | versions.unshift(createVersionSnapshot(row, changeNote)); |
| | | row.versions = versions; |
| | | row.versionNo = (row.versionNo || 1) + 1; |
| | | } |
| | | |
| | | export function validateNewsForm(form) { |
| | | const title = (form.title || "").trim(); |
| | | if (!title) return { ok: false, message: "è¯·å¡«åæ°é»æ é¢" }; |
| | | if (!form.newsType) return { ok: false, message: "è¯·éæ©æ°é»åç±»" }; |
| | | if (form.readScope === "department" && !(form.targetDeptIds || []).length) { |
| | | return { ok: false, message: "è¯·éæ©å¯è§é¨é¨" }; |
| | | } |
| | | return { ok: true, title }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼EnterpriseNews ä¼ä¸æ°é»--> |
| | | <template> |
| | | <div class="app-container enterprise-news-page"> |
| | | |
| | | <div class="search_form mb20"> |
| | | <div class="search_fields"> |
| | | <span class="search_title">å
³é®è¯ï¼</span> |
| | | <el-input |
| | | v-model="searchForm.keyword" |
| | | style="width: 200px" |
| | | placeholder="æ é¢ / ç¼å· / æè¦" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <span class="search_title" style="margin-left: 12px">åç±»ï¼</span> |
| | | <el-select v-model="searchForm.newsType" placeholder="å
¨é¨" clearable style="width: 140px"> |
| | | <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">ç¶æï¼</span> |
| | | <el-select v-model="searchForm.publishStatus" placeholder="å
¨é¨" clearable style="width: 120px"> |
| | | <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">å叿¶é´ï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.publishTimeRange" |
| | | type="daterange" |
| | | range-separator="-" |
| | | start-placeholder="å¼å§" |
| | | end-placeholder="ç»æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 260px" |
| | | clearable |
| | | /> |
| | | <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">æç´¢</el-button> |
| | | <el-button :icon="RefreshRight" @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div class="search_actions"> |
| | | <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">æ°å»ºæ°é»</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | :total="page.total" |
| | | @pagination="pagination" |
| | | > |
| | | <template #newsType="{ row }"> |
| | | <span class="news-type-tag" :style="{ color: newsTypeColor(row.newsType) }"> |
| | | {{ newsTypeLabel(row.newsType) }} |
| | | </span> |
| | | </template> |
| | | </PIMTable> |
| | | </div> |
| | | |
| | | <!-- æ°å»º / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="960px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="news-form-dialog" |
| | | @closed="formRef?.resetFields?.()" |
| | | > |
| | | <el-form |
| | | ref="formRef" |
| | | :model="form" |
| | | :rules="formRules" |
| | | label-width="110px" |
| | | :disabled="formDialog.readonly" |
| | | > |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ°é»åç±»" prop="newsType"> |
| | | <el-select v-model="form.newsType" placeholder="è¯·éæ©" style="width: 100%"> |
| | | <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æç模æ¿"> |
| | | <el-select v-model="form.layoutTemplate" style="width: 100%"> |
| | | <el-option |
| | | v-for="opt in LAYOUT_TEMPLATE_OPTIONS" |
| | | :key="opt.value" |
| | | :label="opt.label" |
| | | :value="opt.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-form-item label="æ é¢" prop="title"> |
| | | <el-input v-model="form.title" placeholder="æ°é»æ é¢" maxlength="100" show-word-limit /> |
| | | </el-form-item> |
| | | <el-form-item label="æè¦"> |
| | | <el-input v-model="form.summary" type="textarea" :rows="2" maxlength="300" show-word-limit /> |
| | | </el-form-item> |
| | | <el-form-item label="æ£æ" prop="contentHtml"> |
| | | <Editor v-model="form.contentHtml" :min-height="280" /> |
| | | </el-form-item> |
| | | <el-form-item label="éä»¶"> |
| | | <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="ä¸ä¼ PDF / ææ¡£" /> |
| | | </el-form-item> |
| | | <el-form-item v-if="form.layoutTemplate === 'gallery'" label="å¾é/è§é¢"> |
| | | <el-input |
| | | v-model="galleryInput" |
| | | placeholder="è¾å
¥èµæºåç§°åå车添å ï¼æ¼ç¤ºï¼" |
| | | @keyup.enter="addGalleryItem" |
| | | /> |
| | | <el-tag |
| | | v-for="(m, i) in form.mediaList" |
| | | :key="i" |
| | | closable |
| | | class="media-tag" |
| | | @close="form.mediaList.splice(i, 1)" |
| | | > |
| | | {{ m.name }} |
| | | </el-tag> |
| | | </el-form-item> |
| | | |
| | | <el-divider content-position="left">æé管æ§</el-divider> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç¼è¾è§è²"> |
| | | <el-select v-model="form.editorRole" style="width: 100%"> |
| | | <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å®¡æ ¸è§è²"> |
| | | <el-select v-model="form.reviewerRole" style="width: 100%"> |
| | | <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-form-item label="é
读èå´" prop="readScope"> |
| | | <el-radio-group v-model="form.readScope"> |
| | | <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value"> |
| | | {{ opt.label }} |
| | | </el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | <el-form-item v-if="form.readScope === 'department'" label="å¯è§é¨é¨"> |
| | | <el-select v-model="form.targetDeptIds" multiple placeholder="éæ©é¨é¨" style="width: 100%"> |
| | | <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="æ¿çç±»å¿
读"> |
| | | <el-switch v-model="form.requireReadConfirm" active-text="éé
读确认ï¼ä¾¿äºç»è®¡æªè¯»ï¼" /> |
| | | </el-form-item> |
| | | <el-form-item label="åå¸äºº"> |
| | | <el-input v-model="form.publisherName" placeholder="å¦ï¼äººåèµæºé¨" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template v-if="!formDialog.readonly" #footer> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | <el-button @click="onSave('save')">åè稿</el-button> |
| | | <el-button type="warning" @click="onSave('submit_review')">æäº¤å®¡æ ¸</el-button> |
| | | <el-button type="primary" @click="onSave('publish')">ç´æ¥åå¸</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="æ°é»è¯¦æ
" width="880px" append-to-body destroy-on-close> |
| | | <NewsDetailPanel |
| | | :row="detailRow" |
| | | @like="onDetailLike" |
| | | @comment="onDetailComment" |
| | | /> |
| | | <template #footer> |
| | | <el-button |
| | | v-if="detailRow.publishStatus === 'published' && getUnreadEmployees(detailRow).length" |
| | | type="warning" |
| | | @click="openUnreadFromDetail" |
| | | > |
| | | æªè¯»æé |
| | | </el-button> |
| | | <el-button @click="openVersionFromDetail">çæ¬çè¯</el-button> |
| | | <el-button @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- æªè¯»æé --> |
| | | <el-dialog |
| | | v-model="unreadDialog.visible" |
| | | :title="`æªé
读åå·¥ · ${unreadDialog.row?.title || ''}`" |
| | | width="720px" |
| | | append-to-body |
| | | destroy-on-close |
| | | > |
| | | <el-alert type="warning" show-icon :closable="false" class="mb12"> |
| | | æ¿çä¼ è¾¾åºæ¯ï¼å叿°èå¤å¶åº¦çå¿
读信æ¯åï¼å¯å¾éæªè¯»åå·¥ç± HR å®åæéï¼æ¼ç¤ºæ°æ®ï¼åæå¯¹æ¥æ¶æ¯ä¸å¿ï¼ã |
| | | </el-alert> |
| | | <div class="unread-toolbar mb12"> |
| | | <el-button size="small" @click="selectAllUnread">å
¨éæªè¯»</el-button> |
| | | <span class="unread-stat">å
± {{ unreadList.length }} 人æªè¯»</span> |
| | | </div> |
| | | <el-table |
| | | :data="unreadList" |
| | | border |
| | | size="small" |
| | | max-height="360" |
| | | @selection-change="onUnreadSelectionChange" |
| | | > |
| | | <el-table-column type="selection" width="48" /> |
| | | <el-table-column prop="employeeNo" label="å·¥å·" width="100" /> |
| | | <el-table-column prop="name" label="å§å" width="90" /> |
| | | <el-table-column prop="deptName" label="é¨é¨" min-width="120" /> |
| | | </el-table> |
| | | <el-divider v-if="unreadDialog.row?.remindLogs?.length" content-position="left">æéè®°å½</el-divider> |
| | | <el-timeline v-if="unreadDialog.row?.remindLogs?.length"> |
| | | <el-timeline-item |
| | | v-for="(log, i) in unreadDialog.row.remindLogs" |
| | | :key="i" |
| | | :timestamp="log.time" |
| | | > |
| | | {{ log.operator }} å·²å {{ log.count }} 人åéé
读æé |
| | | </el-timeline-item> |
| | | </el-timeline> |
| | | <template #footer> |
| | | <el-button type="primary" @click="onSendRemind">åéå®åæé</el-button> |
| | | <el-button @click="unreadDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- çæ¬çè¯ --> |
| | | <el-dialog |
| | | v-model="versionDialog.visible" |
| | | :title="`åå²çæ¬çè¯ Â· ${versionDialog.row?.title || ''}`" |
| | | width="800px" |
| | | append-to-body |
| | | destroy-on-close |
| | | > |
| | | <el-alert type="info" show-icon :closable="false" class="mb12"> |
| | | äºè®®åçæ¶å¯æ¥é
åå²çæ¬ï¼è¯æå½æ¶åå¸å
容ä¸å叿¶é´ï¼åè§çè¯ï¼ã |
| | | </el-alert> |
| | | <el-descriptions :column="2" border class="mb16"> |
| | | <el-descriptions-item label="å½åçæ¬">v{{ versionDialog.row?.versionNo || 1 }}</el-descriptions-item> |
| | | <el-descriptions-item label="æè¿åå¸">{{ versionDialog.row?.publishTime || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | <el-table :data="versionList" border size="small" empty-text="ææ åå²çæ¬"> |
| | | <el-table-column prop="versionNo" label="çæ¬" width="70" align="center" /> |
| | | <el-table-column prop="title" label="æ é¢" min-width="160" show-overflow-tooltip /> |
| | | <el-table-column prop="changeNote" label="åæ´è¯´æ" width="120" /> |
| | | <el-table-column prop="publishTime" label="å叿¶é´" width="170" /> |
| | | <el-table-column prop="archivedAt" label="彿¡£æ¶é´" width="170" /> |
| | | <el-table-column label="æä½" width="90" align="center"> |
| | | <template #default="{ row: ver }"> |
| | | <el-button type="primary" link @click="previewVersion(ver)">æ¥ç</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <template #footer> |
| | | <el-button @click="versionDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- çæ¬é¢è§ --> |
| | | <el-dialog v-model="versionPreview.visible" title="åå²çæ¬å
容" width="640px" append-to-body> |
| | | <p class="version-meta"> |
| | | v{{ versionPreview.data?.versionNo }} · {{ versionPreview.data?.changeNote }} · |
| | | {{ versionPreview.data?.publishTime }} |
| | | </p> |
| | | <div class="version-html" v-html="versionPreview.data?.contentHtml || ''" /> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Plus, RefreshRight } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import { computed, onMounted, reactive, ref } from "vue"; |
| | | import Editor from "@/components/Editor/index.vue"; |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import { newsTypeColor } from "./enterpriseNewsUtils.js"; |
| | | import NewsDetailPanel from "./components/NewsDetailPanel.vue"; |
| | | import { useEnterpriseNews } from "./useEnterpriseNews.js"; |
| | | |
| | | const { |
| | | Search, |
| | | NEWS_TYPE_OPTIONS, |
| | | PUBLISH_STATUS_OPTIONS, |
| | | LAYOUT_TEMPLATE_OPTIONS, |
| | | READ_SCOPE_OPTIONS, |
| | | PUBLISH_ROLE_OPTIONS, |
| | | DEPT_OPTIONS, |
| | | newsTypeLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | formDialog, |
| | | form, |
| | | formRef, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | unreadDialog, |
| | | unreadList, |
| | | versionDialog, |
| | | getUnreadEmployees, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openFormDialog, |
| | | openDetail, |
| | | openUnreadRemind, |
| | | openVersionHistory, |
| | | saveForm, |
| | | sendUnreadRemind, |
| | | toggleLike, |
| | | addComment, |
| | | } = useEnterpriseNews(); |
| | | |
| | | const galleryInput = ref(""); |
| | | const unreadSelected = ref([]); |
| | | const versionPreview = reactive({ visible: false, data: null }); |
| | | |
| | | const versionList = computed(() => { |
| | | const row = versionDialog.row; |
| | | if (!row) return []; |
| | | const history = [...(row.versions || [])]; |
| | | return history.sort((a, b) => (b.versionNo || 0) - (a.versionNo || 0)); |
| | | }); |
| | | |
| | | function addGalleryItem() { |
| | | const name = (galleryInput.value || "").trim(); |
| | | if (!name) return; |
| | | form.mediaList = form.mediaList || []; |
| | | form.mediaList.push({ type: "image", name, url: `/mock/${name}` }); |
| | | galleryInput.value = ""; |
| | | } |
| | | |
| | | function onSave(action) { |
| | | const ret = saveForm(action); |
| | | if (ret?.message) { |
| | | ElMessage.warning(ret.message); |
| | | return; |
| | | } |
| | | if (ret?.ok) { |
| | | ElMessage.success(action === "publish" ? "å·²åå¸" : action === "submit_review" ? "å·²æäº¤å®¡æ ¸" : "å·²ä¿å"); |
| | | } |
| | | } |
| | | |
| | | function onDetailLike() { |
| | | toggleLike(detailRow.value); |
| | | } |
| | | |
| | | function onDetailComment(text) { |
| | | const ret = addComment(detailRow.value, text); |
| | | if (ret?.message) ElMessage.warning(ret.message); |
| | | else if (ret?.ok) ElMessage.success("è¯è®ºå·²åå¸"); |
| | | } |
| | | |
| | | function openUnreadFromDetail() { |
| | | const row = detailRow.value; |
| | | detailDialog.visible = false; |
| | | openUnreadRemind(row); |
| | | } |
| | | |
| | | function openVersionFromDetail() { |
| | | const row = detailRow.value; |
| | | detailDialog.visible = false; |
| | | openVersionHistory(row); |
| | | } |
| | | |
| | | function onUnreadSelectionChange(rows) { |
| | | unreadSelected.value = rows.map((r) => r.userId); |
| | | } |
| | | |
| | | function selectAllUnread() { |
| | | unreadSelected.value = unreadList.value.map((u) => u.userId); |
| | | } |
| | | |
| | | function onSendRemind() { |
| | | const ids = unreadSelected.value; |
| | | const ret = sendUnreadRemind(ids); |
| | | if (ret?.message) { |
| | | ElMessage.warning(ret.message); |
| | | return; |
| | | } |
| | | if (ret?.ok) ElMessage.success(`å·²å ${ret.count} ååå·¥åéé
读æé`); |
| | | } |
| | | |
| | | function previewVersion(ver) { |
| | | versionPreview.data = ver; |
| | | versionPreview.visible = true; |
| | | } |
| | | |
| | | onMounted(() => { |
| | | handleQuery(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .enterprise-news-page .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | gap: 12px; |
| | | } |
| | | .search_fields { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | .search_actions { |
| | | flex-shrink: 0; |
| | | } |
| | | .news-type-tag { |
| | | font-weight: 600; |
| | | font-size: 13px; |
| | | } |
| | | .media-tag { |
| | | margin: 6px 8px 0 0; |
| | | } |
| | | .unread-toolbar { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | } |
| | | .unread-stat { |
| | | color: var(--el-text-color-secondary); |
| | | font-size: 13px; |
| | | } |
| | | .version-meta { |
| | | color: var(--el-text-color-secondary); |
| | | font-size: 13px; |
| | | margin-bottom: 12px; |
| | | } |
| | | .version-html { |
| | | padding: 12px; |
| | | background: var(--el-fill-color-light); |
| | | border-radius: 6px; |
| | | max-height: 400px; |
| | | overflow-y: auto; |
| | | } |
| | | .mb16 { |
| | | margin-bottom: 16px; |
| | | } |
| | | .mb12 { |
| | | margin-bottom: 12px; |
| | | } |
| | | .ml10 { |
| | | margin-left: 10px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import { ElMessageBox } from "element-plus"; |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { |
| | | NEWS_TYPE_OPTIONS, |
| | | PUBLISH_STATUS_OPTIONS, |
| | | LAYOUT_TEMPLATE_OPTIONS, |
| | | READ_SCOPE_OPTIONS, |
| | | PUBLISH_ROLE_OPTIONS, |
| | | DEPT_OPTIONS, |
| | | createEmptyForm, |
| | | createInitialMockNews, |
| | | loadStoredNews, |
| | | saveStoredNews, |
| | | getUnreadEmployees, |
| | | readRate, |
| | | nextNewsNo, |
| | | pushVersionBeforeUpdate, |
| | | validateNewsForm, |
| | | newsTypeLabel, |
| | | publishStatusLabel, |
| | | } from "./enterpriseNewsUtils.js"; |
| | | |
| | | export function useEnterpriseNews() { |
| | | const stored = loadStoredNews(); |
| | | const allRows = ref(stored?.length ? stored : createInitialMockNews()); |
| | | |
| | | const searchForm = reactive({ |
| | | keyword: "", |
| | | newsType: "", |
| | | publishStatus: "", |
| | | publishTimeRange: [], |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | |
| | | const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false }); |
| | | const form = reactive(createEmptyForm()); |
| | | const formRef = ref(); |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const unreadDialog = reactive({ visible: false, row: null }); |
| | | const unreadSelection = ref([]); |
| | | |
| | | const versionDialog = reactive({ visible: false, row: null }); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | const kw = (searchForm.keyword || "").trim().toLowerCase(); |
| | | if (kw) { |
| | | list = list.filter((r) => { |
| | | const title = (r.title || "").toLowerCase(); |
| | | const summary = (r.summary || "").toLowerCase(); |
| | | const no = (r.newsNo || "").toLowerCase(); |
| | | return title.includes(kw) || summary.includes(kw) || no.includes(kw); |
| | | }); |
| | | } |
| | | if (searchForm.newsType) { |
| | | list = list.filter((r) => r.newsType === searchForm.newsType); |
| | | } |
| | | if (searchForm.publishStatus) { |
| | | list = list.filter((r) => r.publishStatus === searchForm.publishStatus); |
| | | } |
| | | const range = searchForm.publishTimeRange; |
| | | if (range?.length === 2 && range[0] && range[1]) { |
| | | const start = dayjs(range[0]).startOf("day"); |
| | | const end = dayjs(range[1]).endOf("day"); |
| | | list = list.filter((r) => { |
| | | if (!r.publishTime) return false; |
| | | const t = dayjs(r.publishTime); |
| | | return t.isAfter(start) && t.isBefore(end); |
| | | }); |
| | | } |
| | | return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1)); |
| | | }); |
| | | |
| | | watch( |
| | | filteredList, |
| | | (list) => { |
| | | page.total = list.length; |
| | | const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); |
| | | if (page.current > maxPage) page.current = maxPage; |
| | | }, |
| | | { immediate: true } |
| | | ); |
| | | |
| | | const tableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return filteredList.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const unreadList = computed(() => { |
| | | if (!unreadDialog.row) return []; |
| | | return getUnreadEmployees(unreadDialog.row); |
| | | }); |
| | | |
| | | const formRules = { |
| | | title: [{ required: true, message: "请è¾å
¥æ°é»æ é¢", trigger: "blur" }], |
| | | newsType: [{ required: true, message: "è¯·éæ©æ°é»åç±»", trigger: "change" }], |
| | | readScope: [{ required: true, message: "è¯·éæ©é
读èå´", trigger: "change" }], |
| | | }; |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç¼å·", prop: "newsNo", width: 150 }, |
| | | { label: "æ é¢", prop: "title", minWidth: 180, showOverflowTooltip: true }, |
| | | { |
| | | label: "åç±»", |
| | | prop: "newsType", |
| | | width: 100, |
| | | dataType: "slot", |
| | | slot: "newsType", |
| | | }, |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "publishStatus", |
| | | width: 90, |
| | | dataType: "tag", |
| | | formatData: (v) => publishStatusLabel(v), |
| | | formatType: (v) => { |
| | | const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v); |
| | | return hit?.tag || "info"; |
| | | }, |
| | | }, |
| | | { |
| | | label: "é
读ç", |
| | | prop: "readRecords", |
| | | width: 90, |
| | | align: "center", |
| | | formatData: (_, row) => `${readRate(row)}%`, |
| | | }, |
| | | { |
| | | label: "æªè¯»", |
| | | prop: "id", |
| | | width: 70, |
| | | align: "center", |
| | | formatData: (_, row) => { |
| | | if (row.publishStatus !== "published") return "â"; |
| | | return getUnreadEmployees(row).length; |
| | | }, |
| | | }, |
| | | { label: "åå¸äºº", prop: "publisherName", width: 110 }, |
| | | { label: "å叿¶é´", prop: "publishTime", width: 170 }, |
| | | { label: "æ´æ°æ¶é´", prop: "updateTime", width: 170 }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 280, |
| | | operation: [ |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | disabled: (row) => row.publishStatus === "archived", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { |
| | | name: "å®¡æ ¸", |
| | | type: "text", |
| | | disabled: (row) => row.publishStatus !== "pending_review", |
| | | clickFun: (row) => openReview(row), |
| | | }, |
| | | { |
| | | name: "æªè¯»æé", |
| | | type: "text", |
| | | disabled: (row) => |
| | | row.publishStatus !== "published" || getUnreadEmployees(row).length === 0, |
| | | clickFun: (row) => openUnreadRemind(row), |
| | | }, |
| | | { name: "çæ¬çè¯", type: "text", clickFun: (row) => openVersionHistory(row) }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | function persist() { |
| | | saveStoredNews(allRows.value); |
| | | } |
| | | |
| | | function handleQuery() { |
| | | tableLoading.value = true; |
| | | page.current = 1; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 200); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.keyword = ""; |
| | | searchForm.newsType = ""; |
| | | searchForm.publishStatus = ""; |
| | | searchForm.publishTimeRange = []; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination({ page: p, limit }) { |
| | | page.current = p; |
| | | page.size = limit; |
| | | } |
| | | |
| | | function resetForm(target = createEmptyForm()) { |
| | | Object.assign(form, createEmptyForm(), target); |
| | | } |
| | | |
| | | function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.readonly = mode === "view"; |
| | | formDialog.title = |
| | | mode === "add" ? "æ°å»ºä¼ä¸æ°é»" : mode === "edit" ? "ç¼è¾ä¼ä¸æ°é»" : "æ¥çä¼ä¸æ°é»"; |
| | | if (mode === "add") { |
| | | resetForm({ publisherName: "å½åç¨æ·" }); |
| | | } else { |
| | | resetForm({ |
| | | ...JSON.parse(JSON.stringify(row)), |
| | | targetDeptIds: [...(row.targetDeptIds || [])], |
| | | targetUserIds: [...(row.targetUserIds || [])], |
| | | mediaList: [...(row.mediaList || [])], |
| | | attachmentList: [...(row.attachmentList || [])], |
| | | }); |
| | | } |
| | | formDialog.visible = true; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openUnreadRemind(row) { |
| | | unreadDialog.row = row; |
| | | unreadSelection.value = []; |
| | | unreadDialog.visible = true; |
| | | } |
| | | |
| | | function openVersionHistory(row) { |
| | | versionDialog.row = row; |
| | | versionDialog.visible = true; |
| | | } |
| | | |
| | | async function openReview(row) { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `ç¡®è®¤å®¡æ ¸éè¿å¹¶åå¸ã${row.title}ãï¼å¤é¨/è¡ä¸ç±»æ°é»é管çåå®¡æ ¸ã`, |
| | | "å®¡æ ¸åå¸", |
| | | { type: "warning", confirmButtonText: "éè¿å¹¶åå¸", cancelButtonText: "åæ¶" } |
| | | ); |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit) return; |
| | | hit.publishStatus = "published"; |
| | | hit.publishTime = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | hit.updateTime = hit.publishTime; |
| | | if (!hit.readRecords?.length) { |
| | | hit.readRecords = []; |
| | | } |
| | | persist(); |
| | | return true; |
| | | } catch { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | function saveForm(submitAction = "save") { |
| | | const v = validateNewsForm(form); |
| | | if (!v.ok) return { ok: false, message: v.message }; |
| | | |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | const payload = { |
| | | ...JSON.parse(JSON.stringify(form)), |
| | | title: v.title, |
| | | updateTime: now, |
| | | }; |
| | | |
| | | if (formDialog.mode === "add") { |
| | | payload.id = `news_${Date.now()}`; |
| | | payload.newsNo = nextNewsNo(); |
| | | payload.createTime = now; |
| | | if (submitAction === "submit_review") { |
| | | payload.publishStatus = "pending_review"; |
| | | } else if (submitAction === "publish") { |
| | | payload.publishStatus = "published"; |
| | | payload.publishTime = now; |
| | | } else { |
| | | payload.publishStatus = "draft"; |
| | | } |
| | | allRows.value.unshift(payload); |
| | | } else { |
| | | const idx = allRows.value.findIndex((r) => r.id === form.id); |
| | | if (idx < 0) return { ok: false, message: "è®°å½ä¸åå¨" }; |
| | | const prev = allRows.value[idx]; |
| | | if (prev.publishStatus === "published" && submitAction !== "draft") { |
| | | pushVersionBeforeUpdate(prev, submitAction === "publish" ? "修订åå¸" : "å
å®¹æ´æ°"); |
| | | } |
| | | if (submitAction === "submit_review") { |
| | | payload.publishStatus = "pending_review"; |
| | | } else if (submitAction === "publish") { |
| | | payload.publishStatus = "published"; |
| | | payload.publishTime = payload.publishTime || now; |
| | | } |
| | | payload.versions = prev.versions || []; |
| | | payload.versionNo = prev.versionNo || 1; |
| | | if (prev.publishStatus === "published" && submitAction === "publish") { |
| | | payload.versionNo = (prev.versionNo || 1) + 1; |
| | | } |
| | | allRows.value[idx] = { ...prev, ...payload }; |
| | | } |
| | | persist(); |
| | | formDialog.visible = false; |
| | | return { ok: true }; |
| | | } |
| | | |
| | | function archiveNews(row) { |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (hit) { |
| | | hit.publishStatus = "archived"; |
| | | hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | persist(); |
| | | } |
| | | } |
| | | |
| | | function sendUnreadRemind(selectedIds) { |
| | | const row = unreadDialog.row; |
| | | if (!row || !selectedIds?.length) return { ok: false, message: "è¯·éæ©è¦æéçåå·¥" }; |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit) return { ok: false }; |
| | | |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | hit.remindLogs = hit.remindLogs || []; |
| | | hit.remindLogs.push({ |
| | | time: now, |
| | | count: selectedIds.length, |
| | | operator: "HR", |
| | | userIds: [...selectedIds], |
| | | }); |
| | | |
| | | const records = hit.readRecords || []; |
| | | selectedIds.forEach((uid) => { |
| | | let rec = records.find((r) => r.userId === uid); |
| | | if (!rec) { |
| | | const emp = getUnreadEmployees(hit).find((e) => e.userId === uid); |
| | | rec = { |
| | | userId: uid, |
| | | employeeNo: emp?.employeeNo || "", |
| | | name: emp?.name || "", |
| | | deptName: emp?.deptName || "", |
| | | readAt: "", |
| | | lastRemindAt: now, |
| | | }; |
| | | records.push(rec); |
| | | } else { |
| | | rec.lastRemindAt = now; |
| | | } |
| | | }); |
| | | hit.readRecords = records; |
| | | hit.updateTime = now; |
| | | persist(); |
| | | unreadDialog.visible = false; |
| | | return { ok: true, count: selectedIds.length }; |
| | | } |
| | | |
| | | function toggleLike(row, userId = "u1", userName = "å¼ ä¸") { |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit) return; |
| | | hit.likes = hit.likes || []; |
| | | const idx = hit.likes.findIndex((l) => l.userId === userId); |
| | | if (idx >= 0) { |
| | | hit.likes.splice(idx, 1); |
| | | } else { |
| | | hit.likes.push({ userId, name: userName, time: dayjs().format("YYYY-MM-DD HH:mm:ss") }); |
| | | } |
| | | persist(); |
| | | if (detailRow.value?.id === row.id) { |
| | | detailRow.value = { ...hit }; |
| | | } |
| | | } |
| | | |
| | | function addComment(row, content, userId = "u1", userName = "å¼ ä¸") { |
| | | const text = (content || "").trim(); |
| | | if (!text) return { ok: false, message: "请è¾å
¥è¯è®ºå
容" }; |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit) return { ok: false }; |
| | | hit.comments = hit.comments || []; |
| | | hit.comments.push({ |
| | | id: `c_${Date.now()}`, |
| | | userId, |
| | | name: userName, |
| | | content: text, |
| | | time: dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }); |
| | | persist(); |
| | | if (detailRow.value?.id === row.id) { |
| | | detailRow.value = { ...hit }; |
| | | } |
| | | return { ok: true }; |
| | | } |
| | | |
| | | return { |
| | | Search, |
| | | NEWS_TYPE_OPTIONS, |
| | | PUBLISH_STATUS_OPTIONS, |
| | | LAYOUT_TEMPLATE_OPTIONS, |
| | | READ_SCOPE_OPTIONS, |
| | | PUBLISH_ROLE_OPTIONS, |
| | | DEPT_OPTIONS, |
| | | newsTypeLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | formDialog, |
| | | form, |
| | | formRef, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | unreadDialog, |
| | | unreadList, |
| | | unreadSelection, |
| | | versionDialog, |
| | | getUnreadEmployees, |
| | | readRate, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openFormDialog, |
| | | openDetail, |
| | | openUnreadRemind, |
| | | openVersionHistory, |
| | | openReview, |
| | | saveForm, |
| | | archiveNews, |
| | | sendUnreadRemind, |
| | | toggleLike, |
| | | addComment, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- NoticeAnnouncementï¼å
¬å详æ
åªè¯»é¢æ¿ --> |
| | | <template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="å
¬åç¼å·">{{ row.noticeNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå¸ç¶æ"> |
| | | <el-tag :type="statusTag" size="small">{{ statusText }}</el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å
¬åç±»å"> |
| | | <span class="type-badge" :style="{ color: noticeTypeColor(row.noticeType) }"> |
| | | {{ noticeTypeLabel(row.noticeType) }} |
| | | </span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="ä¼å
级"> |
| | | <el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="æ é¢" :span="2">{{ row.title || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å叿¥æ">{{ row.publishDate || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="è¿ææ¥æ">{{ row.expireDate || "é¿æææ" }}</el-descriptions-item> |
| | | <el-descriptions-item label="é
读èå´">{{ readScopeLabel(row.readScope) }}</el-descriptions-item> |
| | | <el-descriptions-item label="éé
读确认">{{ row.requireReadConfirm ? "æ¯" : "å¦" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå¸äºº">{{ row.publisherName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å叿¶é´">{{ row.publishTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="é
读é">{{ row.readCount ?? 0 }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-divider content-position="left">å
¬åå
容</el-divider> |
| | | <div v-if="row.priority === 'urgent'" class="urgent-banner"> |
| | | <el-alert title="ç´§æ¥éç¥" type="error" :closable="false" show-icon /> |
| | | </div> |
| | | <div v-if="row.contentHtml" class="notice-html-body" v-html="row.contentHtml" /> |
| | | <el-empty v-else description="ææ å
容" :image-size="48" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { |
| | | noticeTypeLabel, |
| | | noticeTypeColor, |
| | | priorityLabel, |
| | | priorityTag, |
| | | publishStatusLabel, |
| | | publishStatusTag, |
| | | readScopeLabel, |
| | | isExpired, |
| | | } from "../noticeAnnouncementUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const statusText = computed(() => { |
| | | if (isExpired(props.row) && props.row.publishStatus === "published") return "å·²è¿æ"; |
| | | return publishStatusLabel(props.row.publishStatus); |
| | | }); |
| | | |
| | | const statusTag = computed(() => { |
| | | if (isExpired(props.row) && props.row.publishStatus === "published") return ""; |
| | | return publishStatusTag(props.row.publishStatus); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .type-badge { |
| | | font-weight: 600; |
| | | } |
| | | .urgent-banner { |
| | | margin-bottom: 12px; |
| | | } |
| | | .notice-html-body { |
| | | padding: 12px; |
| | | background: var(--el-fill-color-light); |
| | | border-radius: 6px; |
| | | max-height: 400px; |
| | | overflow-y: auto; |
| | | line-height: 1.7; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼NoticeAnnouncement éç¥å
Œ--> |
| | | <template> |
| | | <div class="app-container notice-announcement-page"> |
| | | <div class="search_form mb20"> |
| | | <div class="search_fields"> |
| | | <span class="search_title">å
³é®è¯ï¼</span> |
| | | <el-input |
| | | v-model="searchForm.keyword" |
| | | style="width: 200px" |
| | | placeholder="æ é¢ / ç¼å·" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <span class="search_title" style="margin-left: 12px">ç±»åï¼</span> |
| | | <el-select v-model="searchForm.noticeType" placeholder="å
¨é¨" clearable style="width: 130px"> |
| | | <el-option v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">ä¼å
级ï¼</span> |
| | | <el-select v-model="searchForm.priority" placeholder="å
¨é¨" clearable style="width: 110px"> |
| | | <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">ç¶æï¼</span> |
| | | <el-select v-model="searchForm.publishStatus" placeholder="å
¨é¨" clearable style="width: 110px"> |
| | | <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">å叿¥æï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.publishDateRange" |
| | | type="daterange" |
| | | range-separator="-" |
| | | start-placeholder="å¼å§" |
| | | end-placeholder="ç»æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 260px" |
| | | clearable |
| | | /> |
| | | <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">æç´¢</el-button> |
| | | <el-button :icon="RefreshRight" @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div class="search_actions"> |
| | | <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">æ·»å å
Œ</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | :total="page.total" |
| | | @pagination="pagination" |
| | | > |
| | | <template #noticeType="{ row }"> |
| | | <span class="notice-type-tag" :style="{ color: noticeTypeColor(row.noticeType) }"> |
| | | {{ noticeTypeLabel(row.noticeType) }} |
| | | </span> |
| | | </template> |
| | | </PIMTable> |
| | | </div> |
| | | |
| | | <!-- æ·»å / ä¿®æ¹ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="800px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="notice-form-dialog" |
| | | @closed="formRef?.resetFields?.()" |
| | | > |
| | | <el-form |
| | | ref="formRef" |
| | | :model="form" |
| | | :rules="formRules" |
| | | label-width="100px" |
| | | :disabled="formDialog.readonly" |
| | | > |
| | | <el-form-item label="æ é¢" prop="title"> |
| | | <el-input v-model="form.title" placeholder="请è¾å
¥å
¬åæ é¢" maxlength="100" show-word-limit /> |
| | | </el-form-item> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å
¬åç±»å" prop="noticeType"> |
| | | <el-select v-model="form.noticeType" placeholder="è¯·éæ©" style="width: 100%" @change="onNoticeTypeChange"> |
| | | <el-option v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ä¼å
级"> |
| | | <el-select v-model="form.priority" placeholder="è¯·éæ©" style="width: 100%"> |
| | | <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å叿¥æ" prop="publishDate"> |
| | | <el-date-picker |
| | | v-model="form.publishDate" |
| | | type="date" |
| | | placeholder="å叿¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è¿ææ¥æ"> |
| | | <el-date-picker |
| | | v-model="form.expireDate" |
| | | type="date" |
| | | placeholder="å¯éï¼çç©ºä¸ºé¿æææ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-form-item label="é
读èå´"> |
| | | <el-radio-group v-model="form.readScope"> |
| | | <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value"> |
| | | {{ opt.label }} |
| | | </el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | <el-form-item v-if="form.readScope === 'department'" label="å¯è§é¨é¨"> |
| | | <el-select v-model="form.targetDeptIds" multiple placeholder="éæ©é¨é¨" style="width: 100%"> |
| | | <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item v-if="form.noticeType === 'emergency'" label="å¿
读确认"> |
| | | <el-switch v-model="form.requireReadConfirm" active-text="ç´§æ¥éç¥éå工确认已读" /> |
| | | </el-form-item> |
| | | <el-form-item label="å
容" prop="contentHtml"> |
| | | <Editor v-model="form.contentHtml" :min-height="280" placeholder="请è¾å
¥å
容" /> |
| | | </el-form-item> |
| | | <el-form-item label="åå¸äºº"> |
| | | <el-input v-model="form.publisherName" placeholder="å¦ï¼è¡æ¿é¨" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template v-if="!formDialog.readonly" #footer> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | <el-button @click="onSave(false)">åè稿</el-button> |
| | | <el-button type="primary" @click="onSave(true)">ç¡® å®</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="å
¬å详æ
" width="800px" append-to-body destroy-on-close> |
| | | <NoticeDetailPanel :row="detailRow" /> |
| | | <template #footer> |
| | | <el-button @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Plus, RefreshRight } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import { onMounted } from "vue"; |
| | | import Editor from "@/components/Editor/index.vue"; |
| | | import { noticeTypeColor } from "./noticeAnnouncementUtils.js"; |
| | | import NoticeDetailPanel from "./components/NoticeDetailPanel.vue"; |
| | | import { useNoticeAnnouncement } from "./useNoticeAnnouncement.js"; |
| | | |
| | | const { |
| | | Search, |
| | | NOTICE_TYPE_OPTIONS, |
| | | PRIORITY_OPTIONS, |
| | | PUBLISH_STATUS_OPTIONS, |
| | | READ_SCOPE_OPTIONS, |
| | | DEPT_OPTIONS, |
| | | noticeTypeLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | formDialog, |
| | | form, |
| | | formRef, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openFormDialog, |
| | | saveForm, |
| | | } = useNoticeAnnouncement(); |
| | | |
| | | function onNoticeTypeChange(type) { |
| | | if (type === "emergency") { |
| | | form.priority = "urgent"; |
| | | form.requireReadConfirm = true; |
| | | } |
| | | } |
| | | |
| | | function onSave(publish) { |
| | | const ret = saveForm(publish); |
| | | if (ret?.message) { |
| | | ElMessage.warning(ret.message); |
| | | return; |
| | | } |
| | | if (ret?.ok) { |
| | | ElMessage.success(publish ? "å
¬åå·²åå¸" : "å·²ä¿åè稿"); |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | handleQuery(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .notice-announcement-page .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | gap: 12px; |
| | | } |
| | | .search_fields { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | .search_actions { |
| | | flex-shrink: 0; |
| | | } |
| | | .notice-type-tag { |
| | | font-weight: 600; |
| | | font-size: 13px; |
| | | } |
| | | .ml10 { |
| | | margin-left: 10px; |
| | | } |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | /** å
¬åç±»å */ |
| | | export const NOTICE_TYPE_OPTIONS = [ |
| | | { value: "emergency", label: "ç´§æ¥éç¥", color: "#f56c6c" }, |
| | | { value: "employee", label: "åå·¥å
Œ", color: "#409eff" }, |
| | | { value: "company", label: "ä¼ä¸å
Œ", color: "#e6a23c" }, |
| | | ]; |
| | | |
| | | /** ä¼å
级 */ |
| | | export const PRIORITY_OPTIONS = [ |
| | | { value: "urgent", label: "ç´§æ¥", tag: "danger" }, |
| | | { value: "high", label: "éè¦", tag: "warning" }, |
| | | { value: "normal", label: "æ®é", tag: "info" }, |
| | | ]; |
| | | |
| | | /** åå¸ç¶æ */ |
| | | export const PUBLISH_STATUS_OPTIONS = [ |
| | | { value: "draft", label: "è稿", tag: "info" }, |
| | | { value: "published", label: "å·²åå¸", tag: "success" }, |
| | | { value: "withdrawn", label: "å·²æ¤å", tag: "warning" }, |
| | | { value: "expired", label: "å·²è¿æ", tag: "" }, |
| | | ]; |
| | | |
| | | /** é
读èå´ */ |
| | | export const READ_SCOPE_OPTIONS = [ |
| | | { value: "all", label: "å
¨åå¯è§" }, |
| | | { value: "department", label: "æå®é¨é¨" }, |
| | | { value: "management", label: "管çå±" }, |
| | | ]; |
| | | |
| | | export const DEPT_OPTIONS = [ |
| | | { value: "101", label: "ç åé¨" }, |
| | | { value: "102", label: "éå®é¨" }, |
| | | { value: "103", label: "è¡æ¿é¨" }, |
| | | { value: "104", label: "è´¢å¡é¨" }, |
| | | { value: "105", label: "æ»ç»å" }, |
| | | { value: "106", label: "人åèµæºé¨" }, |
| | | ]; |
| | | |
| | | export const STORAGE_KEY = "oa_notice_announcement_v1"; |
| | | |
| | | export function noticeTypeLabel(v) { |
| | | return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function noticeTypeColor(v) { |
| | | return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399"; |
| | | } |
| | | |
| | | export function priorityLabel(v) { |
| | | return PRIORITY_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function priorityTag(v) { |
| | | return PRIORITY_OPTIONS.find((x) => x.value === v)?.tag || "info"; |
| | | } |
| | | |
| | | export function publishStatusLabel(v) { |
| | | return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function publishStatusTag(v) { |
| | | return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info"; |
| | | } |
| | | |
| | | export function readScopeLabel(v) { |
| | | return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function createEmptyForm() { |
| | | return { |
| | | id: "", |
| | | noticeNo: "", |
| | | title: "", |
| | | noticeType: "employee", |
| | | priority: "normal", |
| | | contentHtml: "", |
| | | publishDate: dayjs().format("YYYY-MM-DD"), |
| | | expireDate: "", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | requireReadConfirm: false, |
| | | publishStatus: "draft", |
| | | publisherName: "", |
| | | publishTime: "", |
| | | readCount: 0, |
| | | createTime: "", |
| | | updateTime: "", |
| | | }; |
| | | } |
| | | |
| | | export function createInitialMockNotices() { |
| | | return [ |
| | | { |
| | | id: "notice_1", |
| | | noticeNo: "NA202605100001", |
| | | title: "å
³äºå°é£å¤©æ°å±
å®¶åå
¬çç´§æ¥éç¥", |
| | | noticeType: "emergency", |
| | | priority: "urgent", |
| | | contentHtml: |
| | | "<p><strong>ç´§æ¥éç¥</strong></p><p>åå°é£å½±åï¼ææ¥ï¼5æ17æ¥ï¼å
¨ä½åå·¥å±
å®¶åå
¬ï¼è¯·åé¨é¨è´è´£äººå好工ä½å®æä¸åå·¥èç»ã</p>", |
| | | publishDate: "2026-05-16", |
| | | expireDate: "2026-05-20", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | requireReadConfirm: true, |
| | | publishStatus: "published", |
| | | publisherName: "è¡æ¿é¨", |
| | | publishTime: "2026-05-16 08:30:00", |
| | | readCount: 128, |
| | | createTime: "2026-05-16 08:00:00", |
| | | updateTime: "2026-05-16 08:30:00", |
| | | }, |
| | | { |
| | | id: "notice_2", |
| | | noticeNo: "NA202605120002", |
| | | title: "2026年端åèæ¾å宿å
Œ", |
| | | noticeType: "employee", |
| | | priority: "high", |
| | | contentHtml: |
| | | "<p>æ ¹æ®å½å®¶æ³å®è忥宿ï¼ç«¯åèæ¾åæ¶é´ä¸º 6æ8æ¥è³6æ10æ¥ï¼å
±3天ã6æ7æ¥ï¼å¨å
ï¼æ£å¸¸ä¸çã</p>", |
| | | publishDate: "2026-05-12", |
| | | expireDate: "2026-06-15", |
| | | readScope: "all", |
| | | targetDeptIds: [], |
| | | requireReadConfirm: false, |
| | | publishStatus: "published", |
| | | publisherName: "人åèµæºé¨", |
| | | publishTime: "2026-05-12 10:00:00", |
| | | readCount: 256, |
| | | createTime: "2026-05-12 09:30:00", |
| | | updateTime: "2026-05-12 10:00:00", |
| | | }, |
| | | { |
| | | id: "notice_3", |
| | | noticeNo: "NA202605140003", |
| | | title: "åå
¬åºåæ¶é²æ¼ç»éç¥", |
| | | noticeType: "company", |
| | | priority: "normal", |
| | | contentHtml: "<p>å®äº 5æ25æ¥ 14:00 卿»é¨å¤§æ¥¼è¿è¡æ¶é²æ¼ç»ï¼è¯·åé¨é¨æåå®æäººååå ã</p>", |
| | | publishDate: "2026-05-14", |
| | | expireDate: "2026-05-26", |
| | | readScope: "department", |
| | | targetDeptIds: ["101", "102", "103"], |
| | | requireReadConfirm: false, |
| | | publishStatus: "draft", |
| | | publisherName: "è¡æ¿é¨", |
| | | publishTime: "", |
| | | readCount: 0, |
| | | createTime: "2026-05-14 15:00:00", |
| | | updateTime: "2026-05-14 15:00:00", |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | export function loadStoredNotices() { |
| | | try { |
| | | const raw = localStorage.getItem(STORAGE_KEY); |
| | | if (!raw) return null; |
| | | const data = JSON.parse(raw); |
| | | return Array.isArray(data) ? data : null; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function saveStoredNotices(rows) { |
| | | try { |
| | | localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); |
| | | } catch { |
| | | /* ignore */ |
| | | } |
| | | } |
| | | |
| | | export function nextNoticeNo() { |
| | | return `NA${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`; |
| | | } |
| | | |
| | | export function validateNoticeForm(form) { |
| | | const title = (form.title || "").trim(); |
| | | if (!title) return { ok: false, message: "请è¾å
¥å
¬åæ é¢" }; |
| | | if (!form.publishDate) return { ok: false, message: "è¯·éæ©å叿¥æ" }; |
| | | if (!form.noticeType) return { ok: false, message: "è¯·éæ©å
¬åç±»å" }; |
| | | if (form.readScope === "department" && !(form.targetDeptIds || []).length) { |
| | | return { ok: false, message: "è¯·éæ©å¯è§é¨é¨" }; |
| | | } |
| | | return { ok: true, title }; |
| | | } |
| | | |
| | | export function isExpired(row) { |
| | | if (!row.expireDate) return false; |
| | | return dayjs(row.expireDate).endOf("day").isBefore(dayjs()); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import { ElMessageBox } from "element-plus"; |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { |
| | | NOTICE_TYPE_OPTIONS, |
| | | PRIORITY_OPTIONS, |
| | | PUBLISH_STATUS_OPTIONS, |
| | | READ_SCOPE_OPTIONS, |
| | | DEPT_OPTIONS, |
| | | createEmptyForm, |
| | | createInitialMockNotices, |
| | | loadStoredNotices, |
| | | saveStoredNotices, |
| | | nextNoticeNo, |
| | | validateNoticeForm, |
| | | noticeTypeLabel, |
| | | priorityLabel, |
| | | publishStatusLabel, |
| | | isExpired, |
| | | } from "./noticeAnnouncementUtils.js"; |
| | | |
| | | export function useNoticeAnnouncement() { |
| | | const stored = loadStoredNotices(); |
| | | const allRows = ref(stored?.length ? stored : createInitialMockNotices()); |
| | | |
| | | const searchForm = reactive({ |
| | | keyword: "", |
| | | noticeType: "", |
| | | priority: "", |
| | | publishStatus: "", |
| | | publishDateRange: [], |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | |
| | | const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false }); |
| | | const form = reactive(createEmptyForm()); |
| | | const formRef = ref(); |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | const kw = (searchForm.keyword || "").trim().toLowerCase(); |
| | | if (kw) { |
| | | list = list.filter((r) => (r.title || "").toLowerCase().includes(kw) || (r.noticeNo || "").toLowerCase().includes(kw)); |
| | | } |
| | | if (searchForm.noticeType) list = list.filter((r) => r.noticeType === searchForm.noticeType); |
| | | if (searchForm.priority) list = list.filter((r) => r.priority === searchForm.priority); |
| | | if (searchForm.publishStatus) list = list.filter((r) => r.publishStatus === searchForm.publishStatus); |
| | | const range = searchForm.publishDateRange; |
| | | if (range?.length === 2 && range[0] && range[1]) { |
| | | const start = dayjs(range[0]).startOf("day"); |
| | | const end = dayjs(range[1]).endOf("day"); |
| | | list = list.filter((r) => { |
| | | if (!r.publishDate) return false; |
| | | const t = dayjs(r.publishDate); |
| | | return !t.isBefore(start) && !t.isAfter(end); |
| | | }); |
| | | } |
| | | return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1)); |
| | | }); |
| | | |
| | | watch( |
| | | filteredList, |
| | | (list) => { |
| | | page.total = list.length; |
| | | const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); |
| | | if (page.current > maxPage) page.current = maxPage; |
| | | }, |
| | | { immediate: true } |
| | | ); |
| | | |
| | | const tableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return filteredList.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const formRules = { |
| | | title: [{ required: true, message: "请è¾å
¥å
¬åæ é¢", trigger: "blur" }], |
| | | publishDate: [{ required: true, message: "è¯·éæ©å叿¥æ", trigger: "change" }], |
| | | noticeType: [{ required: true, message: "è¯·éæ©å
¬åç±»å", trigger: "change" }], |
| | | }; |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç¼å·", prop: "noticeNo", width: 150 }, |
| | | { label: "æ é¢", prop: "title", minWidth: 200, showOverflowTooltip: true }, |
| | | { |
| | | label: "ç±»å", |
| | | prop: "noticeType", |
| | | width: 100, |
| | | dataType: "slot", |
| | | slot: "noticeType", |
| | | }, |
| | | { |
| | | label: "ä¼å
级", |
| | | prop: "priority", |
| | | width: 90, |
| | | dataType: "tag", |
| | | formatData: (v) => priorityLabel(v), |
| | | formatType: (v) => { |
| | | const hit = PRIORITY_OPTIONS.find((x) => x.value === v); |
| | | return hit?.tag || "info"; |
| | | }, |
| | | }, |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "publishStatus", |
| | | width: 90, |
| | | dataType: "tag", |
| | | formatData: (v, row) => (isExpired(row) && v === "published" ? "å·²è¿æ" : publishStatusLabel(v)), |
| | | formatType: (v, row) => { |
| | | if (isExpired(row) && v === "published") return ""; |
| | | const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v); |
| | | return hit?.tag || "info"; |
| | | }, |
| | | }, |
| | | { label: "å叿¥æ", prop: "publishDate", width: 120 }, |
| | | { label: "åå¸äºº", prop: "publisherName", width: 110 }, |
| | | { label: "é
读é", prop: "readCount", width: 80, align: "center" }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 220, |
| | | operation: [ |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { |
| | | name: "ä¿®æ¹", |
| | | type: "text", |
| | | disabled: (row) => row.publishStatus === "withdrawn", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { |
| | | name: "åå¸", |
| | | type: "text", |
| | | disabled: (row) => row.publishStatus === "published", |
| | | clickFun: (row) => publishNotice(row), |
| | | }, |
| | | { |
| | | name: "æ¤å", |
| | | type: "text", |
| | | disabled: (row) => row.publishStatus !== "published", |
| | | clickFun: (row) => withdrawNotice(row), |
| | | }, |
| | | { name: "å é¤", type: "text", clickFun: (row) => deleteNotice(row) }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | function persist() { |
| | | saveStoredNotices(allRows.value); |
| | | } |
| | | |
| | | function handleQuery() { |
| | | tableLoading.value = true; |
| | | page.current = 1; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 200); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.keyword = ""; |
| | | searchForm.noticeType = ""; |
| | | searchForm.priority = ""; |
| | | searchForm.publishStatus = ""; |
| | | searchForm.publishDateRange = []; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination({ page: p, limit }) { |
| | | page.current = p; |
| | | page.size = limit; |
| | | } |
| | | |
| | | function resetForm(target = createEmptyForm()) { |
| | | Object.assign(form, createEmptyForm(), target); |
| | | } |
| | | |
| | | function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.readonly = mode === "view"; |
| | | formDialog.title = |
| | | mode === "add" ? "æ·»å å
¬å" : mode === "edit" ? "ä¿®æ¹å
¬å" : "æ¥çå
Œ"; |
| | | if (mode === "add") { |
| | | resetForm({ publisherName: "å½åç¨æ·", priority: "normal" }); |
| | | } else { |
| | | resetForm({ |
| | | ...JSON.parse(JSON.stringify(row)), |
| | | targetDeptIds: [...(row.targetDeptIds || [])], |
| | | }); |
| | | } |
| | | formDialog.visible = true; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function saveForm(publish = false) { |
| | | const v = validateNoticeForm(form); |
| | | if (!v.ok) return { ok: false, message: v.message }; |
| | | |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | const payload = { |
| | | ...JSON.parse(JSON.stringify(form)), |
| | | title: v.title, |
| | | updateTime: now, |
| | | }; |
| | | |
| | | if (form.noticeType === "emergency" && payload.priority === "normal") { |
| | | payload.priority = "urgent"; |
| | | } |
| | | |
| | | if (formDialog.mode === "add") { |
| | | payload.id = `notice_${Date.now()}`; |
| | | payload.noticeNo = nextNoticeNo(); |
| | | payload.createTime = now; |
| | | payload.readCount = 0; |
| | | if (publish) { |
| | | payload.publishStatus = "published"; |
| | | payload.publishTime = now; |
| | | } else { |
| | | payload.publishStatus = "draft"; |
| | | } |
| | | allRows.value.unshift(payload); |
| | | } else { |
| | | const idx = allRows.value.findIndex((r) => r.id === form.id); |
| | | if (idx < 0) return { ok: false, message: "è®°å½ä¸åå¨" }; |
| | | const prev = allRows.value[idx]; |
| | | if (publish) { |
| | | payload.publishStatus = "published"; |
| | | payload.publishTime = payload.publishTime || now; |
| | | } |
| | | allRows.value[idx] = { ...prev, ...payload }; |
| | | } |
| | | persist(); |
| | | formDialog.visible = false; |
| | | return { ok: true }; |
| | | } |
| | | |
| | | async function publishNotice(row) { |
| | | try { |
| | | await ElMessageBox.confirm(`确认åå¸ã${row.title}ãï¼`, "åå¸å
Œ", { |
| | | type: "warning", |
| | | confirmButtonText: "åå¸", |
| | | cancelButtonText: "åæ¶", |
| | | }); |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit) return; |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | hit.publishStatus = "published"; |
| | | hit.publishTime = now; |
| | | hit.updateTime = now; |
| | | if (hit.noticeType === "emergency") hit.priority = "urgent"; |
| | | persist(); |
| | | return true; |
| | | } catch { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | async function withdrawNotice(row) { |
| | | try { |
| | | await ElMessageBox.confirm(`确认æ¤åã${row.title}ãï¼æ¤ååå工端å°ä¸åå±ç¤ºã`, "æ¤åå
Œ", { |
| | | type: "warning", |
| | | confirmButtonText: "æ¤å", |
| | | cancelButtonText: "åæ¶", |
| | | }); |
| | | const hit = allRows.value.find((r) => r.id === row.id); |
| | | if (!hit) return; |
| | | hit.publishStatus = "withdrawn"; |
| | | hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | persist(); |
| | | return true; |
| | | } catch { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | async function deleteNotice(row) { |
| | | try { |
| | | await ElMessageBox.confirm(`确认å é¤ã${row.title}ãï¼æ¤æä½ä¸å¯æ¢å¤ã`, "å é¤å
Œ", { |
| | | type: "warning", |
| | | confirmButtonText: "å é¤", |
| | | cancelButtonText: "åæ¶", |
| | | }); |
| | | allRows.value = allRows.value.filter((r) => r.id !== row.id); |
| | | persist(); |
| | | return true; |
| | | } catch { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | Search, |
| | | NOTICE_TYPE_OPTIONS, |
| | | PRIORITY_OPTIONS, |
| | | PUBLISH_STATUS_OPTIONS, |
| | | READ_SCOPE_OPTIONS, |
| | | DEPT_OPTIONS, |
| | | noticeTypeLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | formDialog, |
| | | form, |
| | | formRef, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | isExpired, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | openFormDialog, |
| | | openDetail, |
| | | saveForm, |
| | | publishNotice, |
| | | withdrawNotice, |
| | | deleteNotice, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- è´¹ç¨æ¥éï¼è¯¦æ
åªè¯»é¢æ¿ --> |
| | | <template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="æ¥éåå·">{{ row.reimburseNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥éç¶æ"> |
| | | <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="è´¹ç¨ç±»å">{{ expenseCategoryLabel(row.expenseCategory) }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·æ¶é´">{{ row.applyTime || row.createTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå·¥ç¼å·">{{ row.employeeNo || row.applicantNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå·¥å§å">{{ row.employeeName || row.applicantName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥éåå " :span="2">{{ row.reimburseReason || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥ééé¢">{{ row.applyAmount != null ? `${row.applyAmount} å
` : "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¶æ¬¾äºº">{{ row.payee || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¶æ¬¾è´¦å·">{{ row.payeeAccount || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="弿·æ¯è¡">{{ row.bankBranch || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item v-if="row.rejectReason" label="驳ååå " :span="2"> |
| | | <span class="reject-text">{{ row.rejectReason }}</span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´" :span="2">{{ row.createTime || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-divider content-position="left">æ¥éæç»</el-divider> |
| | | <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small"> |
| | | <el-table-column type="index" label="åºå·" width="55" align="center" /> |
| | | <el-table-column prop="invoiceDate" label="åç¥¨æ¥æ" width="120" /> |
| | | <el-table-column label="è´¹ç¨ç§ç®" width="100"> |
| | | <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="amount" label="éé¢" width="100" /> |
| | | <el-table-column prop="description" label="æè¿°" min-width="140" show-overflow-tooltip /> |
| | | </el-table> |
| | | <el-empty v-else description="ææ æç»" :image-size="48" /> |
| | | |
| | | <el-divider content-position="left">å票éä»¶</el-divider> |
| | | <template v-if="attachmentFiles.length"> |
| | | <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)"> |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <el-empty v-else description="ææ éä»¶" :image-size="48" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { expenseCategoryLabel, expenseSubjectLabel, statusLabel, statusTagType } from "../costReimburseUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const attachmentFiles = computed(() => { |
| | | const list = props.row?.attachmentList?.length ? props.row.attachmentList : props.row?.invoiceAttachments; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | function openFile(f) { |
| | | const url = f?.url || f?.downloadURL || f?.previewURL; |
| | | if (url) window.open(url, "_blank"); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .reject-text { |
| | | color: var(--el-color-danger); |
| | | } |
| | | .file-tag { |
| | | margin: 0 8px 8px 0; |
| | | cursor: pointer; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | /** è´¹ç¨æ¥é大类 */ |
| | | export const EXPENSE_CATEGORY_OPTIONS = [ |
| | | { label: "å·®æ
", value: "travel" }, |
| | | { label: "åå
¬éè´", value: "office_procurement" }, |
| | | { label: "ä¸å¡æå¾
", value: "business_entertainment" }, |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "é讯费", value: "communication" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | /** æç»è´¹ç¨ç§ç® */ |
| | | export const EXPENSE_SUBJECT_OPTIONS = [ |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "ä½å®¿è´¹", value: "hotel" }, |
| | | { label: "é¤é¥®è´¹", value: "meal" }, |
| | | { label: "åå
¬ç¨å", value: "office_supply" }, |
| | | { label: "æå¾
è´¹", value: "entertainment" }, |
| | | { label: "é讯费", value: "phone" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | /** åç±»å¡«æ¥æ¨¡æ¿ï¼ä¸é®è°ç¨ï¼ */ |
| | | export const CATEGORY_TEMPLATES = { |
| | | travel: { |
| | | label: "å·®æ
è´¹ç¨", |
| | | reason: "å å
¬åºå·®äº§çç交éãä½å®¿ãé¤é¥®çè´¹ç¨æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "transport", description: "å¾è¿äº¤éè´¹" }, |
| | | { expenseSubject: "hotel", description: "ä½å®¿è´¹" }, |
| | | { expenseSubject: "meal", description: "åºå·®é¤é¥®" }, |
| | | ], |
| | | }, |
| | | office_procurement: { |
| | | label: "åå
¬éè´", |
| | | reason: "é¨é¨æ¥å¸¸åå
¬ç¨åãèæéè´æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "office_supply", description: "åå
¬ç¨åéè´" }, |
| | | { expenseSubject: "office_supply", description: "æå°èæ" }, |
| | | ], |
| | | }, |
| | | business_entertainment: { |
| | | label: "ä¸å¡æå¾
", |
| | | reason: "å®¢æ·æ¥å¾
ãåå¡å®´è¯·çè´¹ç¨æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "entertainment", description: "å®¢æ·æ¥å¾
é¤è´¹" }, |
| | | { expenseSubject: "entertainment", description: "åå¡ç¤¼å" }, |
| | | ], |
| | | }, |
| | | transport: { |
| | | label: "交éè´¹", |
| | | reason: "å¸å
éå¤ãæè½¦ãå车ç交éè´¹ç¨æ¥éã", |
| | | details: [{ expenseSubject: "transport", description: "å¸å
交é" }], |
| | | }, |
| | | communication: { |
| | | label: "é讯费", |
| | | reason: "å å
¬éè®¯ãæµéãè¯è´¹è¡¥è´´æ¥éã", |
| | | details: [{ expenseSubject: "phone", description: "è¯è´¹/æµé" }], |
| | | }, |
| | | other: { |
| | | label: "å
¶ä»è´¹ç¨", |
| | | reason: "å
¶ä»å å
¬æ¯åºè´¹ç¨æ¥éã", |
| | | details: [{ expenseSubject: "other", description: "å
¶ä»è´¹ç¨" }], |
| | | }, |
| | | }; |
| | | |
| | | /** 审æ¹è§è²ä¸æ¨¡æå®¡æ¹äºº */ |
| | | export const MOCK_APPROVERS_BY_ROLE = { |
| | | direct_supervisor: { approverId: "mock_supervisor", approverName: "ç´å±ä¸çº§" }, |
| | | dept_manager: { approverId: "mock_manager", approverName: "é¨é¨ç»ç" }, |
| | | cfo: { approverId: "mock_cfo", approverName: "è´¢å¡æ»ç" }, |
| | | compliance: { approverId: "mock_compliance", approverName: "åè§å®¡æ ¸" }, |
| | | }; |
| | | |
| | | /** æéé¢é¢è®¾å®¡æ¹é¾ */ |
| | | export const APPROVAL_AMOUNT_RULES = [ |
| | | { |
| | | maxAmount: 500, |
| | | description: "500å
以å
ï¼ç´å±ä¸çº§å®¡æ¹", |
| | | roles: ["direct_supervisor"], |
| | | }, |
| | | { |
| | | maxAmount: 5000, |
| | | description: "500ï½5000å
ï¼ç´å±ä¸çº§ + é¨é¨ç»ç", |
| | | roles: ["direct_supervisor", "dept_manager"], |
| | | }, |
| | | { |
| | | maxAmount: Infinity, |
| | | description: "è¶
5000å
ï¼ç´å±ä¸çº§ + é¨é¨ç»ç + è´¢å¡æ»ç夿 ¸", |
| | | roles: ["direct_supervisor", "dept_manager", "cfo"], |
| | | }, |
| | | ]; |
| | | |
| | | /** é¨ååç±»é¢å¤å®¡æ¹èç¹ */ |
| | | export const CATEGORY_EXTRA_APPROVAL = { |
| | | business_entertainment: ["compliance"], |
| | | office_procurement: [], |
| | | }; |
| | | |
| | | export function expenseCategoryLabel(v) { |
| | | return EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function expenseSubjectLabel(v) { |
| | | return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function statusLabel(v) { |
| | | if (v === "approved") return "å·²éè¿"; |
| | | if (v === "rejected") return "已驳å"; |
| | | return "å®¡æ ¸ä¸"; |
| | | } |
| | | |
| | | export function statusTagType(v) { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | return "warning"; |
| | | } |
| | | |
| | | export function formatApprovalFlowSummary(row) { |
| | | const nodes = row?.approvalFlowNodes || []; |
| | | if (!nodes.length) return "â"; |
| | | return nodes |
| | | .map((n, i) => { |
| | | const name = (n.approverName || "").trim() || `èç¹${i + 1}`; |
| | | if (n.nodeStatus === "finish") return `${name}â`; |
| | | if (n.nodeStatus === "error") return `${name}â`; |
| | | if (n.nodeStatus === "process") return `${name}â¦`; |
| | | return name; |
| | | }) |
| | | .join(" â "); |
| | | } |
| | | |
| | | export function resolveApprovalRoles(amount, expenseCategory) { |
| | | const amt = Number(amount) || 0; |
| | | let roles = []; |
| | | for (const rule of APPROVAL_AMOUNT_RULES) { |
| | | if (amt <= rule.maxAmount) { |
| | | roles = [...rule.roles]; |
| | | break; |
| | | } |
| | | } |
| | | if (!roles.length) roles = ["direct_supervisor"]; |
| | | const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || []; |
| | | extra.forEach((r) => { |
| | | if (!roles.includes(r)) roles.push(r); |
| | | }); |
| | | return roles; |
| | | } |
| | | |
| | | export function buildAutoApprovalFlow(amount, expenseCategory) { |
| | | const roles = resolveApprovalRoles(amount, expenseCategory); |
| | | return roles.map((role, i) => { |
| | | const mock = MOCK_APPROVERS_BY_ROLE[role] || { approverId: `mock_${role}`, approverName: role }; |
| | | return { |
| | | approverId: mock.approverId, |
| | | approverName: mock.approverName, |
| | | roleKey: role, |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | nodeStatus: i === 0 ? "process" : "wait", |
| | | approveOpinion: "", |
| | | approveTime: "", |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | export function getApprovalRuleHint(amount, expenseCategory) { |
| | | const amt = Number(amount) || 0; |
| | | const rule = APPROVAL_AMOUNT_RULES.find((r) => amt <= r.maxAmount) || APPROVAL_AMOUNT_RULES[APPROVAL_AMOUNT_RULES.length - 1]; |
| | | const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || []; |
| | | const extraText = extra.length |
| | | ? `ï¼${expenseCategoryLabel(expenseCategory)}ç±»å¦éï¼${extra.map((r) => MOCK_APPROVERS_BY_ROLE[r]?.approverName || r).join("ã")}` |
| | | : ""; |
| | | return `${rule.description}${extraText}`; |
| | | } |
| | | |
| | | export function createEmptyExpenseDetail() { |
| | | return { |
| | | id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: undefined, |
| | | description: "", |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyForm() { |
| | | return { |
| | | id: undefined, |
| | | reimburseNo: "", |
| | | applicantId: "", |
| | | employeeNo: "", |
| | | employeeName: "", |
| | | expenseCategory: "", |
| | | reimburseReason: "", |
| | | applyAmount: undefined, |
| | | payee: "", |
| | | payeeAccount: "", |
| | | bankBranch: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [], |
| | | currentNodeIndex: 0, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | deptId: "", |
| | | deptName: "", |
| | | }; |
| | | } |
| | | |
| | | export function applyCategoryTemplate(form, category) { |
| | | const tpl = CATEGORY_TEMPLATES[category]; |
| | | if (!tpl) return; |
| | | form.expenseCategory = category; |
| | | if (!form.reimburseReason?.trim()) form.reimburseReason = tpl.reason; |
| | | form.expenseDetails = (tpl.details || []).map((d) => ({ |
| | | ...createEmptyExpenseDetail(), |
| | | expenseSubject: d.expenseSubject, |
| | | description: d.description, |
| | | invoiceDate: dayjs().format("YYYY-MM-DD"), |
| | | })); |
| | | } |
| | | |
| | | export function initApprovalFlowNodes(nodes) { |
| | | return (nodes || []).map((n, i) => ({ |
| | | ...n, |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | nodeStatus: i === 0 ? "process" : "wait", |
| | | approveOpinion: n.approveOpinion || "", |
| | | approveTime: n.approveTime || "", |
| | | })); |
| | | } |
| | | |
| | | export function advanceApprovalFlow(row, opinion) { |
| | | const nodes = [...(row.approvalFlowNodes || [])]; |
| | | const idx = row.currentNodeIndex ?? 0; |
| | | if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult }; |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | nodes[idx] = { |
| | | ...nodes[idx], |
| | | nodeStatus: "finish", |
| | | approveOpinion: opinion || "åæ", |
| | | approveTime: now, |
| | | }; |
| | | const next = idx + 1; |
| | | if (next >= nodes.length) { |
| | | return { nodes, currentNodeIndex: idx, approvalResult: "approved", rejectReason: "" }; |
| | | } |
| | | nodes[next] = { ...nodes[next], nodeStatus: "process" }; |
| | | return { nodes, currentNodeIndex: next, approvalResult: "pending", rejectReason: "" }; |
| | | } |
| | | |
| | | export function rejectApprovalFlow(row, opinion) { |
| | | const nodes = [...(row.approvalFlowNodes || [])]; |
| | | const idx = row.currentNodeIndex ?? 0; |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | const reason = (opinion || "").trim() || "驳å"; |
| | | if (nodes[idx]) { |
| | | nodes[idx] = { |
| | | ...nodes[idx], |
| | | nodeStatus: "error", |
| | | approveOpinion: reason, |
| | | approveTime: now, |
| | | }; |
| | | } |
| | | return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: reason }; |
| | | } |
| | | |
| | | export function normalizeImportedRow(raw, idx) { |
| | | const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`; |
| | | const expenseDetails = Array.isArray(raw.expenseDetails) ? raw.expenseDetails : []; |
| | | const applyAmount = raw.applyAmount ?? expenseDetails.reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | const expenseCategory = raw.expenseCategory || "other"; |
| | | const approvalFlowNodes = |
| | | Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length |
| | | ? raw.approvalFlowNodes |
| | | : buildAutoApprovalFlow(applyAmount, expenseCategory); |
| | | |
| | | return { |
| | | id, |
| | | reimburseNo: raw.reimburseNo || `CR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`, |
| | | applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`, |
| | | employeeNo: raw.employeeNo ?? raw.applicantNo ?? "", |
| | | employeeName: raw.employeeName ?? raw.applicantName ?? "æªç¥", |
| | | applicantNo: raw.employeeNo ?? raw.applicantNo ?? "", |
| | | applicantName: raw.employeeName ?? raw.applicantName ?? "æªç¥", |
| | | expenseCategory, |
| | | reimburseReason: raw.reimburseReason ?? "", |
| | | applyAmount, |
| | | payee: raw.payee ?? "", |
| | | payeeAccount: raw.payeeAccount ?? "", |
| | | bankBranch: raw.bankBranch ?? "", |
| | | expenseDetails, |
| | | attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [], |
| | | invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [], |
| | | approvalFlowNodes, |
| | | currentNodeIndex: raw.currentNodeIndex ?? 0, |
| | | approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending", |
| | | rejectReason: raw.rejectReason ?? "", |
| | | approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [], |
| | | applyTime: raw.applyTime || raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | deptId: raw.deptId ?? "", |
| | | deptName: raw.deptName ?? "", |
| | | }; |
| | | } |
| | |
| | | <!-- |
| | | 模å䏿åï¼è´¹ç¨æ¥é |
| | | ç®å½æ è¯ï¼ReimburseManage/cost-reimburseï¼cost-reimburse â 䏿ï¼è´¹ç¨æ¥éï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <!--OA模åï¼è´¹ç¨æ¥é--> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div class="search_fields"> |
| | | <span class="search_title">ç³è¯·äººï¼</span> |
| | | <el-input |
| | | v-model="searchForm.applicantKeyword" |
| | | style="width: 220px" |
| | | placeholder="å§åæç¼å·" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <span class="search_title" style="margin-left: 12px">ç³è¯·æ¶é´ï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.applyTimeFrom" |
| | | type="date" |
| | | placeholder="å¼å§æ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 150px" |
| | | clearable |
| | | /> |
| | | <span class="search_title" style="margin-left: 8px">è³</span> |
| | | <el-date-picker |
| | | v-model="searchForm.applyTimeTo" |
| | | type="date" |
| | | placeholder="ç»ææ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 150px; margin-left: 8px" |
| | | clearable |
| | | /> |
| | | <el-button type="primary" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div class="search_actions"> |
| | | <el-button type="success" plain @click="handleImportClick">导å
¥</el-button> |
| | | <el-button type="warning" plain @click="handleExport">导åº</el-button> |
| | | <el-button type="primary" @click="openFormDialog('add')">æ°å¢è´¹ç¨æ¥é</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" /> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | :total="page.total" |
| | | @pagination="pagination" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="1120px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="cost-reimburse-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-alert type="info" show-icon :closable="false" class="mb16"> |
| | | <template #title>å
¨åç±»è´¹ç¨æ¥é · å类模æ¿ä¸é®å¡«æ¥</template> |
| | | <template #default> |
| | | æ¯æå·®æ
ãåå
¬éè´ãä¸å¡æå¾
ã交éè´¹ãé讯费çï¼æéé¢èªå¨å¹é
审æ¹é¾ï¼500å
å
ç´å±ä¸çº§ï¼è¶
5000å
è´¢å¡æ»ç夿 ¸ï¼ã |
| | | </template> |
| | | </el-alert> |
| | | |
| | | <div v-if="!formDialog.readonly" class="template-bar mb16"> |
| | | <span class="template-label">å类模æ¿ï¼</span> |
| | | <el-button |
| | | v-for="(tpl, key) in CATEGORY_TEMPLATES" |
| | | :key="key" |
| | | size="small" |
| | | :type="form.expenseCategory === key ? 'primary' : 'default'" |
| | | plain |
| | | @click="applyTemplate(key)" |
| | | > |
| | | {{ tpl.label }} |
| | | </el-button> |
| | | </div> |
| | | |
| | | <el-form |
| | | ref="formRef" |
| | | :model="form" |
| | | :rules="formRules" |
| | | label-width="120px" |
| | | class="cost-reimburse-form" |
| | | :disabled="formDialog.readonly" |
| | | > |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">åºæ¬ä¿¡æ¯</span></template> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åå·¥ç¼å·"> |
| | | <el-input v-model="form.employeeNo" readonly placeholder="éæ©åå·¥åèªå¨å¸¦åº" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åå·¥å§å" prop="applicantId"> |
| | | <el-select |
| | | v-model="form.applicantId" |
| | | filterable |
| | | remote |
| | | clearable |
| | | reserve-keyword |
| | | placeholder="è¯·éæ©ææç´¢åå·¥" |
| | | style="width: 100%" |
| | | :remote-method="remoteSearchApplicantForm" |
| | | :loading="applicantFormSearchLoading" |
| | | @change="onApplicantChange" |
| | | > |
| | | <el-option |
| | | v-for="u in applicantFormOptions" |
| | | :key="u.userId" |
| | | :label="userSelectLabel(u)" |
| | | :value="u.userId" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è´¹ç¨ç±»å" prop="expenseCategory"> |
| | | <el-select |
| | | v-model="form.expenseCategory" |
| | | placeholder="è¯·éæ©è´¹ç¨ç±»å" |
| | | style="width: 100%" |
| | | @change="onExpenseCategoryChange" |
| | | > |
| | | <el-option |
| | | v-for="opt in EXPENSE_CATEGORY_OPTIONS" |
| | | :key="opt.value" |
| | | :label="opt.label" |
| | | :value="opt.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ¥éç¶æ"> |
| | | <el-tag |
| | | :type="form.approvalResult === 'approved' ? 'success' : form.approvalResult === 'rejected' ? 'danger' : 'warning'" |
| | | effect="plain" |
| | | > |
| | | {{ |
| | | form.approvalResult === "approved" |
| | | ? "å·²éè¿" |
| | | : form.approvalResult === "rejected" |
| | | ? "已驳å" |
| | | : "å®¡æ ¸ä¸" |
| | | }} |
| | | </el-tag> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="æ¥éåå " prop="reimburseReason"> |
| | | <el-input |
| | | v-model="form.reimburseReason" |
| | | type="textarea" |
| | | :rows="3" |
| | | placeholder="è¯·å¡«åæ¥éåå " |
| | | maxlength="2000" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ¥ééé¢" prop="applyAmount"> |
| | | <div class="amount-row"> |
| | | <el-input-number |
| | | v-model="form.applyAmount" |
| | | :min="0" |
| | | :precision="2" |
| | | controls-position="right" |
| | | class="amount-input" |
| | | @change="autoAssignApprovalFlow" |
| | | /> |
| | | <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails"> |
| | | ææç»æ±æ» {{ detailTotalAmount }} å
|
| | | </el-button> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header> |
| | | <div class="card-header-row"> |
| | | <span class="card-header-title">æ¥éæç»</span> |
| | | <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail"> |
| | | æ°å¢æç» |
| | | </el-button> |
| | | </div> |
| | | </template> |
| | | |
| | | <el-table :data="form.expenseDetails" border size="small" class="detail-table"> |
| | | <el-table-column type="index" label="åºå·" width="55" align="center" /> |
| | | <el-table-column label="åç¥¨æ¥æ" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-if="!formDialog.readonly" |
| | | v-model="row.invoiceDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | size="small" |
| | | style="width: 100%" |
| | | /> |
| | | <span v-else>{{ row.invoiceDate || "â" }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="è´¹ç¨ç§ç®" width="130"> |
| | | <template #default="{ row }"> |
| | | <el-select v-if="!formDialog.readonly" v-model="row.expenseSubject" size="small" style="width: 100%"> |
| | | <el-option |
| | | v-for="opt in EXPENSE_SUBJECT_OPTIONS" |
| | | :key="opt.value" |
| | | :label="opt.label" |
| | | :value="opt.value" |
| | | /> |
| | | </el-select> |
| | | <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="éé¢" width="120"> |
| | | <template #default="{ row }"> |
| | | <el-input-number |
| | | v-if="!formDialog.readonly" |
| | | v-model="row.amount" |
| | | :min="0" |
| | | :precision="2" |
| | | size="small" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | @change="onDetailAmountChange" |
| | | /> |
| | | <span v-else>{{ row.amount ?? "â" }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿°" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说æ" /> |
| | | <span v-else>{{ row.description || "â" }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column v-if="!formDialog.readonly" label="æä½" width="70" align="center"> |
| | | <template #default="{ $index }"> |
| | | <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">å é¤</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">æ¶æ¬¾ä¿¡æ¯</span></template> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æ¶æ¬¾äºº" prop="payee"> |
| | | <el-input v-model="form.payee" placeholder="请è¾å
¥æ¶æ¬¾äºº" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æ¶æ¬¾è´¦å·" prop="payeeAccount"> |
| | | <el-input v-model="form.payeeAccount" placeholder="é¶è¡å¡å·" maxlength="30" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="弿·æ¯è¡" prop="bankBranch"> |
| | | <el-input v-model="form.bankBranch" placeholder="弿·æ¯è¡å
¨ç§°" maxlength="100" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">éä»¶ï¼å票ï¼</span></template> |
| | | <el-form-item label-width="0" class="attachment-form-item"> |
| | | <div class="upload-block"> |
| | | <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="ç¹å»éæ©æä»¶" /> |
| | | </div> |
| | | </el-form-item> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header> |
| | | <div class="card-header-row"> |
| | | <span class="card-header-title">å®¡æ¹æµç¨</span> |
| | | <el-button v-if="!formDialog.readonly" type="primary" link size="small" @click="autoAssignApprovalFlow"> |
| | | æè§åéæ°åé
|
| | | </el-button> |
| | | </div> |
| | | </template> |
| | | <el-alert type="success" :title="approvalRuleHint" show-icon :closable="false" class="mb12" /> |
| | | <el-form-item prop="approvalFlowNodes" label-width="0"> |
| | | <ApprovalFlowEditor |
| | | v-if="!formDialog.readonly" |
| | | v-model="form.approvalFlowNodes" |
| | | :user-options="flowUserOptions" |
| | | @update:model-value="onApprovalFlowChange" |
| | | /> |
| | | <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" /> |
| | | <p v-if="!formDialog.readonly" class="flow-tip">ç³»ç»å·²æéé¢ä¸è´¹ç¨ç±»åèªå¨åé
审æ¹äººï¼å¯æå¨è°æ´ã</p> |
| | | </el-form-item> |
| | | </el-card> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">æ 交</el-button> |
| | | <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "å
³ é" : "å æ¶" }}</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="è´¹ç¨æ¥é详æ
" width="900px" append-to-body destroy-on-close> |
| | | <DetailPanel :row="detailRow" /> |
| | | <el-divider content-position="left">å®¡æ¹æµç¨</el-divider> |
| | | <ApprovalFlowProgress :nodes="detailRow.approvalFlowNodes" :current-index="detailRow.currentNodeIndex ?? 0" /> |
| | | <el-divider content-position="left">审æ¹è®°å½</el-divider> |
| | | <el-timeline v-if="detailRow.approvalRecords?.length"> |
| | | <el-timeline-item |
| | | v-for="(rec, i) in detailRow.approvalRecords" |
| | | :key="i" |
| | | :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'" |
| | | :timestamp="rec.time" |
| | | > |
| | | {{ rec.operatorName }} â {{ approvalActionLabel(rec.result) }}ï¼{{ rec.opinion || "æ æè§" }} |
| | | </el-timeline-item> |
| | | </el-timeline> |
| | | <el-empty v-else description="ææ å®¡æ¹è®°å½" :image-size="60" /> |
| | | <template #footer> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- å®¡æ¹ --> |
| | | <el-dialog |
| | | v-model="approveDialog.visible" |
| | | title="è´¹ç¨æ¥é审æ¹" |
| | | width="1000px" |
| | | append-to-body |
| | | destroy-on-close |
| | | @closed="approveOpinion = ''" |
| | | > |
| | | <DetailPanel :row="approveDialog.row" /> |
| | | <el-divider content-position="left">æµç¨è¿åº¦</el-divider> |
| | | <ApprovalFlowProgress |
| | | :nodes="approveDialog.row?.approvalFlowNodes" |
| | | :current-index="approveDialog.row?.currentNodeIndex ?? 0" |
| | | /> |
| | | <el-form label-width="100px" class="mt16"> |
| | | <el-form-item label="å®¡æ¹æè§" required> |
| | | <el-input |
| | | v-model="approveOpinion" |
| | | type="textarea" |
| | | :rows="3" |
| | | maxlength="500" |
| | | show-word-limit |
| | | placeholder="éè¿å¯ç空ï¼é©³å请填åå
·ä½åå ï¼å¦ï¼å票模ç³ééä¼ ï¼" |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button type="success" @click="submitApprove('approved')">é è¿</el-button> |
| | | <el-button type="danger" @click="submitApprove('rejected')">驳 å</el-button> |
| | | <el-button @click="approveDialog.visible = false">å æ¶</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue' |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue"; |
| | | import ApprovalFlowProgress from "../travel-reimburse/components/ApprovalFlowProgress.vue"; |
| | | import DetailPanel from "./components/DetailPanel.vue"; |
| | | import { useCostReimburse } from "./useCostReimburse.js"; |
| | | |
| | | const cr = useCostReimburse(); |
| | | const { |
| | | Search, |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | CATEGORY_TEMPLATES, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseSubjectLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | importInputRef, |
| | | formRef, |
| | | form, |
| | | formDialog, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | approveDialog, |
| | | approveOpinion, |
| | | applicantFormSearchLoading, |
| | | applicantFormOptions, |
| | | flowUserOptions, |
| | | detailTotalAmount, |
| | | approvalRuleHint, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | remoteSearchApplicantForm, |
| | | userSelectLabel, |
| | | onApplicantChange, |
| | | onExpenseCategoryChange, |
| | | applyTemplate, |
| | | onDetailAmountChange, |
| | | onApprovalFlowChange, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | syncApplyAmountFromDetails, |
| | | autoAssignApprovalFlow, |
| | | openFormDialog, |
| | | onFormClosed, |
| | | submitForm, |
| | | approvalActionLabel, |
| | | submitApprove, |
| | | handleExport, |
| | | handleImportClick, |
| | | onImportFile, |
| | | } = cr; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .mb16 { |
| | | margin-bottom: 16px; |
| | | } |
| | | .mb12 { |
| | | margin-bottom: 12px; |
| | | } |
| | | .mt16 { |
| | | margin-top: 16px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_fields { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | .search_actions { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | } |
| | | .search_title { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-regular); |
| | | } |
| | | .sr-only-input { |
| | | position: absolute; |
| | | width: 1px; |
| | | height: 1px; |
| | | padding: 0; |
| | | margin: -1px; |
| | | overflow: hidden; |
| | | clip: rect(0, 0, 0, 0); |
| | | white-space: nowrap; |
| | | border: 0; |
| | | } |
| | | .template-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | .template-label { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-secondary); |
| | | flex-shrink: 0; |
| | | } |
| | | .form-section { |
| | | margin-bottom: 16px; |
| | | border: 1px solid var(--el-border-color-lighter); |
| | | } |
| | | .form-section :deep(.el-card__header) { |
| | | padding: 12px 16px; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .form-section :deep(.el-card__body) { |
| | | padding: 16px 16px 4px; |
| | | } |
| | | .card-header-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | } |
| | | .card-header-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .amount-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | width: 100%; |
| | | } |
| | | .amount-input { |
| | | flex: 1; |
| | | min-width: 160px; |
| | | } |
| | | .attachment-form-item { |
| | | margin-bottom: 0; |
| | | } |
| | | .detail-table { |
| | | margin-bottom: 0; |
| | | } |
| | | .upload-block { |
| | | width: 100%; |
| | | } |
| | | .flow-tip { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | margin-top: 8px; |
| | | } |
| | | .cost-reimburse-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | .cost-reimburse-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .cost-reimburse-form :deep(.el-input-number) { |
| | | width: 100%; |
| | | } |
| | | .cost-reimburse-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | import { |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | CATEGORY_TEMPLATES, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseCategoryLabel, |
| | | expenseSubjectLabel, |
| | | statusLabel, |
| | | statusTagType, |
| | | formatApprovalFlowSummary, |
| | | buildAutoApprovalFlow, |
| | | getApprovalRuleHint, |
| | | createEmptyExpenseDetail, |
| | | createEmptyForm, |
| | | applyCategoryTemplate, |
| | | initApprovalFlowNodes, |
| | | advanceApprovalFlow, |
| | | rejectApprovalFlow, |
| | | normalizeImportedRow, |
| | | } from "./costReimburseUtils.js"; |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload?.data && Array.isArray(payload.data)) return payload.data; |
| | | if (payload?.rows && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | function demoFlowNodes(amount = 1200, category = "transport") { |
| | | return buildAutoApprovalFlow(amount, category); |
| | | } |
| | | |
| | | export function useCostReimburse() { |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | reimburseNo: "CR202605100001", |
| | | applicantId: "mock_1", |
| | | employeeNo: "zhangsan", |
| | | employeeName: "å¼ ä¸", |
| | | applicantNo: "zhangsan", |
| | | applicantName: "å¼ ä¸", |
| | | expenseCategory: "office_procurement", |
| | | reimburseReason: "éè´æå°æºç¡é¼ãA4纸çåå
¬èæã", |
| | | applyAmount: 680, |
| | | payee: "å¼ ä¸", |
| | | payeeAccount: "6222 **** **** 1234", |
| | | bankBranch: "ä¸å½å·¥åé¶è¡æå·è¥¿æ¹æ¯è¡", |
| | | expenseDetails: [ |
| | | { id: "d1", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 380, description: "A4å¤å°çº¸" }, |
| | | { id: "d2", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 300, description: "ç¡é¼" }, |
| | | ], |
| | | attachmentList: [{ name: "éè´å票.pdf", url: "/mock/invoice1.pdf" }], |
| | | approvalFlowNodes: demoFlowNodes(680, "office_procurement"), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | approvalRecords: [], |
| | | applyTime: "2026-05-10 09:15:00", |
| | | createTime: "2026-05-10 09:15:00", |
| | | deptId: "101", |
| | | deptName: "è¡æ¿é¨", |
| | | }, |
| | | { |
| | | id: "2", |
| | | reimburseNo: "CR202605080002", |
| | | applicantId: "mock_2", |
| | | employeeNo: "lisi", |
| | | employeeName: "æå", |
| | | applicantNo: "lisi", |
| | | applicantName: "æå", |
| | | expenseCategory: "business_entertainment", |
| | | reimburseReason: "æ¥å¾
éç¹å®¢æ·åå¡å®´è¯·ã", |
| | | applyAmount: 3200, |
| | | payee: "æå", |
| | | payeeAccount: "6217 **** **** 5678", |
| | | bankBranch: "æåé¶è¡æ¦æ±å
è°·æ¯è¡", |
| | | expenseDetails: [ |
| | | { id: "d3", invoiceDate: "2026-05-06", expenseSubject: "entertainment", amount: 3200, description: "客æ·å®´è¯·" }, |
| | | ], |
| | | attachmentList: [], |
| | | approvalFlowNodes: demoFlowNodes(3200, "business_entertainment").map((n, i) => ({ |
| | | ...n, |
| | | nodeStatus: i === 0 ? "error" : "wait", |
| | | approveOpinion: i === 0 ? "å票模ç³ééä¼ " : "", |
| | | approveTime: i === 0 ? "2026-05-09 14:20:00" : "", |
| | | })), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "rejected", |
| | | rejectReason: "å票模ç³ééä¼ ", |
| | | approvalRecords: [ |
| | | { operatorName: "ç´å±ä¸çº§", result: "rejected", opinion: "å票模ç³ééä¼ ", time: "2026-05-09 14:20:00" }, |
| | | ], |
| | | applyTime: "2026-05-07 16:30:00", |
| | | createTime: "2026-05-07 16:30:00", |
| | | deptId: "102", |
| | | deptName: "éå®é¨", |
| | | }, |
| | | { |
| | | id: "3", |
| | | reimburseNo: "CR202605050003", |
| | | applicantId: "mock_3", |
| | | employeeNo: "wangwu", |
| | | employeeName: "çäº", |
| | | applicantNo: "wangwu", |
| | | applicantName: "çäº", |
| | | expenseCategory: "communication", |
| | | reimburseReason: "5æå å
¬è¯è´¹æ¥éã", |
| | | applyAmount: 198, |
| | | payee: "çäº", |
| | | payeeAccount: "6228 **** **** 9012", |
| | | bankBranch: "ä¸å½å»ºè®¾é¶è¡æé½é«æ°æ¯è¡", |
| | | expenseDetails: [ |
| | | { id: "d4", invoiceDate: "2026-05-05", expenseSubject: "phone", amount: 198, description: "è¯è´¹è´¦å" }, |
| | | ], |
| | | attachmentList: [{ name: "è¯è´¹è´¦å.jpg", url: "/mock/phone.jpg" }], |
| | | approvalFlowNodes: demoFlowNodes(198, "communication").map((n) => ({ |
| | | ...n, |
| | | nodeStatus: "finish", |
| | | approveOpinion: "åæ", |
| | | approveTime: "2026-05-06 10:00:00", |
| | | })), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "approved", |
| | | rejectReason: "", |
| | | approvalRecords: [{ operatorName: "ç´å±ä¸çº§", result: "approved", opinion: "åæ", time: "2026-05-06 10:00:00" }], |
| | | applyTime: "2026-05-05 11:00:00", |
| | | createTime: "2026-05-05 11:00:00", |
| | | deptId: "103", |
| | | deptName: "ææ¯é¨", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantKeyword: "", |
| | | applyTimeFrom: "", |
| | | applyTimeTo: "", |
| | | }); |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | const importInputRef = ref(null); |
| | | const allUsersCache = ref([]); |
| | | const applicantFormSearchLoading = ref(false); |
| | | const applicantFormOptions = ref([]); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false }); |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | const approveDialog = reactive({ visible: false, row: null }); |
| | | const approveOpinion = ref(""); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | const kw = (searchForm.applicantKeyword || "").trim().toLowerCase(); |
| | | if (kw) { |
| | | list = list.filter((r) => { |
| | | const name = (r.applicantName || r.employeeName || "").toLowerCase(); |
| | | const no = (r.applicantNo || r.employeeNo || "").toLowerCase(); |
| | | return name.includes(kw) || no.includes(kw); |
| | | }); |
| | | } |
| | | if (searchForm.applyTimeFrom) { |
| | | list = list.filter((r) => { |
| | | const t = (r.applyTime || r.createTime || "").slice(0, 10); |
| | | return !t || t >= searchForm.applyTimeFrom; |
| | | }); |
| | | } |
| | | if (searchForm.applyTimeTo) { |
| | | list = list.filter((r) => { |
| | | const t = (r.applyTime || r.createTime || "").slice(0, 10); |
| | | return !t || t <= searchForm.applyTimeTo; |
| | | }); |
| | | } |
| | | return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1)); |
| | | }); |
| | | |
| | | watch( |
| | | filteredList, |
| | | (list) => { |
| | | page.total = list.length; |
| | | const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); |
| | | if (page.current > maxPage) page.current = maxPage; |
| | | }, |
| | | { immediate: true } |
| | | ); |
| | | |
| | | const tableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return filteredList.value.slice(start, start + page.size).map((r) => ({ |
| | | ...r, |
| | | approvalFlowSummary: formatApprovalFlowSummary(r), |
| | | })); |
| | | }); |
| | | |
| | | const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser)); |
| | | |
| | | const detailTotalAmount = computed(() => { |
| | | const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | return Math.round(sum * 100) / 100; |
| | | }); |
| | | |
| | | const approvalRuleHint = computed(() => |
| | | getApprovalRuleHint(form.applyAmount ?? detailTotalAmount.value, form.expenseCategory) |
| | | ); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "æ¥éåå·", prop: "reimburseNo", width: 150 }, |
| | | { label: "ç³è¯·äººç¼å·", prop: "applicantNo", width: 110 }, |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 90 }, |
| | | { label: "æ¥ééé¢(å
)", prop: "applyAmount", width: 110 }, |
| | | { label: "æ¥éåå ", prop: "reimburseReason", minWidth: 160, showOverflowTooltip: true }, |
| | | { label: "ç³è¯·æ¶é´", prop: "applyTime", width: 165 }, |
| | | { label: "å建æ¶é´", prop: "createTime", width: 165 }, |
| | | { |
| | | label: "æ¥éç¶æ", |
| | | prop: "approvalResult", |
| | | width: 100, |
| | | dataType: "tag", |
| | | formatData: (v) => statusLabel(v), |
| | | formatType: (v) => statusTagType(v), |
| | | }, |
| | | { |
| | | label: "å®¡æ¹æµç¨", |
| | | prop: "approvalFlowSummary", |
| | | minWidth: 200, |
| | | showOverflowTooltip: true, |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 220, |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { |
| | | name: "审æ¹", |
| | | type: "text", |
| | | disabled: (row) => row.approvalResult !== "pending", |
| | | clickFun: (row) => openApprove(row), |
| | | }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formRules = { |
| | | applicantId: [{ required: true, message: "è¯·éæ©åå·¥", trigger: "change" }], |
| | | expenseCategory: [{ required: true, message: "è¯·éæ©è´¹ç¨ç±»å", trigger: "change" }], |
| | | reimburseReason: [{ required: true, message: "è¯·å¡«åæ¥éåå ", trigger: "blur" }], |
| | | applyAmount: [{ required: true, message: "è¯·å¡«åæ¥ééé¢", trigger: "blur" }], |
| | | payee: [{ required: true, message: "è¯·å¡«åæ¶æ¬¾äºº", trigger: "blur" }], |
| | | payeeAccount: [{ required: true, message: "è¯·å¡«åæ¶æ¬¾è´¦å·", trigger: "blur" }], |
| | | bankBranch: [{ required: true, message: "请填å弿·æ¯è¡", trigger: "blur" }], |
| | | approvalFlowNodes: [ |
| | | { |
| | | validator: (_r, _v, cb) => { |
| | | const nodes = form.approvalFlowNodes || []; |
| | | if (!nodes.length) { |
| | | cb(new Error("请è³å°é
ç½®ä¸ä¸ªå®¡æ¹èç¹")); |
| | | return; |
| | | } |
| | | if (nodes.some((n) => n.approverId == null || n.approverId === "")) { |
| | | cb(new Error("æ¯ä¸ªèç¹é¡»éæ©å®¡æ¹äºº")); |
| | | return; |
| | | } |
| | | cb(); |
| | | }, |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | allUsersCache.value = unwrapArray(await userListNoPageByTenantId()); |
| | | } catch { |
| | | allUsersCache.value = []; |
| | | } |
| | | } |
| | | |
| | | function userSelectLabel(u) { |
| | | const nick = u.nickName || ""; |
| | | const name = u.userName || ""; |
| | | if (nick && name && nick !== name) return `${nick}ï¼${name}ï¼`; |
| | | return nick || name || `ç¨æ·${u.userId ?? u.id ?? ""}`; |
| | | } |
| | | |
| | | function userById(id) { |
| | | return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function employeeNoFromUser(u) { |
| | | if (!u) return ""; |
| | | return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : ""); |
| | | } |
| | | |
| | | function filterUsersByQuery(query) { |
| | | const list = allUsersCache.value.filter(isActiveUser); |
| | | const q = (query || "").trim().toLowerCase(); |
| | | if (!q) return [...list]; |
| | | return list.filter((u) => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const uname = (u.userName || "").toLowerCase(); |
| | | return nick.includes(q) || uname.includes(q); |
| | | }); |
| | | } |
| | | |
| | | async function remoteSearchApplicantForm(query) { |
| | | applicantFormSearchLoading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | applicantFormOptions.value = filterUsersByQuery(query); |
| | | } finally { |
| | | applicantFormSearchLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | function onApplicantChange(uid) { |
| | | const u = userById(uid); |
| | | if (u) { |
| | | form.employeeName = u.nickName || u.userName || ""; |
| | | form.employeeNo = employeeNoFromUser(u); |
| | | form.payee = form.payee || form.employeeName; |
| | | form.deptId = String(u.deptId ?? u.sysDeptId ?? ""); |
| | | form.deptName = u.dept?.deptName ?? u.deptName ?? ""; |
| | | } else { |
| | | form.employeeName = ""; |
| | | form.employeeNo = ""; |
| | | } |
| | | } |
| | | |
| | | function autoAssignApprovalFlow() { |
| | | const amount = Number(form.applyAmount) || detailTotalAmount.value || 0; |
| | | form.approvalFlowNodes = buildAutoApprovalFlow(amount, form.expenseCategory || "other"); |
| | | nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); |
| | | } |
| | | |
| | | function onExpenseCategoryChange(val) { |
| | | if (val && !(form.expenseDetails || []).length) { |
| | | applyCategoryTemplate(form, val); |
| | | syncApplyAmountFromDetails(); |
| | | } |
| | | autoAssignApprovalFlow(); |
| | | } |
| | | |
| | | function applyTemplate(category) { |
| | | applyCategoryTemplate(form, category); |
| | | syncApplyAmountFromDetails(); |
| | | autoAssignApprovalFlow(); |
| | | proxy?.$modal?.msgSuccess?.(`å·²åºç¨ã${CATEGORY_TEMPLATES[category]?.label || category}ãå¡«æ¥æ¨¡æ¿`); |
| | | } |
| | | |
| | | function onDetailAmountChange() { |
| | | syncApplyAmountFromDetails(); |
| | | autoAssignApprovalFlow(); |
| | | } |
| | | |
| | | function onApprovalFlowChange() { |
| | | nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); |
| | | } |
| | | |
| | | function addExpenseDetail() { |
| | | form.expenseDetails.push(createEmptyExpenseDetail()); |
| | | } |
| | | |
| | | function removeExpenseDetail(index) { |
| | | form.expenseDetails.splice(index, 1); |
| | | syncApplyAmountFromDetails(); |
| | | autoAssignApprovalFlow(); |
| | | } |
| | | |
| | | function syncApplyAmountFromDetails() { |
| | | form.applyAmount = detailTotalAmount.value; |
| | | } |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.applicantKeyword = ""; |
| | | searchForm.applyTimeFrom = ""; |
| | | searchForm.applyTimeTo = ""; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openApprove(row) { |
| | | approveDialog.row = { ...row }; |
| | | approveDialog.visible = true; |
| | | } |
| | | |
| | | function approvalActionLabel(v) { |
| | | if (v === "approved") return "éè¿"; |
| | | if (v === "rejected") return "驳å"; |
| | | return "æäº¤"; |
| | | } |
| | | |
| | | async function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.readonly = false; |
| | | formDialog.title = mode === "add" ? "æ°å¢è´¹ç¨æ¥é" : "ç¼è¾è´¹ç¨æ¥é"; |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | Object.assign(form, createEmptyForm()); |
| | | if (mode === "edit" && row) { |
| | | Object.assign(form, { |
| | | ...JSON.parse(JSON.stringify(row)), |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])), |
| | | approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])), |
| | | expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])), |
| | | }); |
| | | const u = userById(row.applicantId); |
| | | applicantFormOptions.value = u |
| | | ? [u] |
| | | : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }]; |
| | | } else { |
| | | form.approvalFlowNodes = buildAutoApprovalFlow(0, "other"); |
| | | remoteSearchApplicantForm(""); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => { |
| | | formRef.value?.clearValidate?.(); |
| | | }); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | if (!(form.expenseDetails || []).length) { |
| | | proxy?.$modal?.msgWarning?.("请è³å°æ·»å 䏿¡æ¥éæç»"); |
| | | return; |
| | | } |
| | | syncApplyAmountFromDetails(); |
| | | autoAssignApprovalFlow(); |
| | | |
| | | const payload = { |
| | | reimburseNo: form.reimburseNo || `CR${dayjs().format("YYYYMMDDHHmmss")}`, |
| | | applicantId: form.applicantId, |
| | | employeeNo: form.employeeNo, |
| | | employeeName: form.employeeName, |
| | | applicantNo: form.employeeNo, |
| | | applicantName: form.employeeName, |
| | | expenseCategory: form.expenseCategory, |
| | | reimburseReason: form.reimburseReason, |
| | | applyAmount: form.applyAmount, |
| | | payee: form.payee, |
| | | payeeAccount: form.payeeAccount, |
| | | bankBranch: form.bankBranch, |
| | | expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)), |
| | | attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])), |
| | | invoiceAttachments: (form.attachmentList || []).map((f, i) => ({ |
| | | id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`, |
| | | name: f.name || f.fileName || "æªå½å", |
| | | url: f.url || f.downloadURL || "", |
| | | })), |
| | | approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes), |
| | | currentNodeIndex: 0, |
| | | deptId: form.deptId, |
| | | deptName: form.deptName, |
| | | }; |
| | | |
| | | if (formDialog.mode === "add") { |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | allRows.value.unshift({ |
| | | id: `local_${Date.now()}`, |
| | | ...payload, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | approvalRecords: [], |
| | | applyTime: now, |
| | | createTime: now, |
| | | }); |
| | | proxy?.$modal?.msgSuccess?.("æäº¤æåï¼å·²è¿å
¥å®¡æ¹ï¼æ¬å°æ¨¡æï¼"); |
| | | } else { |
| | | const idx = allRows.value.findIndex((r) => r.id === form.id); |
| | | if (idx !== -1) { |
| | | const prev = allRows.value[idx]; |
| | | allRows.value[idx] = { |
| | | ...prev, |
| | | ...payload, |
| | | id: form.id, |
| | | approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult, |
| | | approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes), |
| | | currentNodeIndex: 0, |
| | | rejectReason: prev.approvalResult === "rejected" ? "" : prev.rejectReason, |
| | | applyTime: prev.applyTime, |
| | | createTime: prev.createTime, |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | async function submitApprove(result) { |
| | | const row = approveDialog.row; |
| | | if (!row) return; |
| | | if (result === "rejected" && !(approveOpinion.value || "").trim()) { |
| | | proxy?.$modal?.msgWarning?.("驳å须填åå®¡æ¹æè§ï¼å¦ï¼å票模ç³ééä¼ ï¼"); |
| | | return; |
| | | } |
| | | const idx = allRows.value.findIndex((r) => r.id === row.id); |
| | | if (idx === -1) return; |
| | | const cur = allRows.value[idx]; |
| | | const operatorName = "å½å审æ¹äºº"; |
| | | const record = { |
| | | operatorName, |
| | | result, |
| | | opinion: approveOpinion.value || (result === "approved" ? "åæ" : "驳å"), |
| | | time: dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }; |
| | | const records = [...(cur.approvalRecords || []), record]; |
| | | let flowUpdate; |
| | | if (result === "approved") { |
| | | flowUpdate = advanceApprovalFlow(cur, approveOpinion.value); |
| | | } else { |
| | | flowUpdate = rejectApprovalFlow(cur, approveOpinion.value); |
| | | } |
| | | allRows.value[idx] = { |
| | | ...cur, |
| | | approvalFlowNodes: flowUpdate.nodes, |
| | | currentNodeIndex: flowUpdate.currentNodeIndex, |
| | | approvalResult: flowUpdate.approvalResult, |
| | | rejectReason: flowUpdate.rejectReason ?? cur.rejectReason, |
| | | approvalRecords: records, |
| | | }; |
| | | proxy?.$modal?.msgSuccess?.(result === "approved" ? "å·²éè¿" : "已驳å"); |
| | | approveDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function handleExport() { |
| | | const data = filteredList.value; |
| | | const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }); |
| | | const url = URL.createObjectURL(blob); |
| | | const a = document.createElement("a"); |
| | | a.href = url; |
| | | a.download = `è´¹ç¨æ¥é导åº_${dayjs().format("YYYYMMDDHHmmss")}.json`; |
| | | a.click(); |
| | | URL.revokeObjectURL(url); |
| | | proxy?.$modal?.msgSuccess?.(`å·²å¯¼åº ${data.length} æ¡`); |
| | | } |
| | | |
| | | function handleImportClick() { |
| | | importInputRef.value?.click?.(); |
| | | } |
| | | |
| | | function onImportFile(e) { |
| | | const file = e.target.files?.[0]; |
| | | e.target.value = ""; |
| | | if (!file) return; |
| | | const reader = new FileReader(); |
| | | reader.onload = () => { |
| | | try { |
| | | const parsed = JSON.parse(String(reader.result || "")); |
| | | const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data; |
| | | if (!Array.isArray(arr) || !arr.length) { |
| | | proxy?.$modal?.msgWarning?.("导å
¥æ ¼å¼é¡»ä¸ºè´¹ç¨æ¥é JSON æ°ç»"); |
| | | return; |
| | | } |
| | | arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i))); |
| | | proxy?.$modal?.msgSuccess?.(`æå导å
¥ ${arr.length} æ¡`); |
| | | handleQuery(); |
| | | } catch { |
| | | proxy?.$modal?.msgError?.("è§£æå¤±è´¥"); |
| | | } |
| | | }; |
| | | reader.readAsText(file, "utf-8"); |
| | | } |
| | | |
| | | onMounted(() => loadUserPool()); |
| | | |
| | | return { |
| | | Search, |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | CATEGORY_TEMPLATES, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseCategoryLabel, |
| | | expenseSubjectLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | importInputRef, |
| | | formRef, |
| | | form, |
| | | formDialog, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | approveDialog, |
| | | approveOpinion, |
| | | applicantFormSearchLoading, |
| | | applicantFormOptions, |
| | | flowUserOptions, |
| | | detailTotalAmount, |
| | | approvalRuleHint, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | remoteSearchApplicantForm, |
| | | userSelectLabel, |
| | | onApplicantChange, |
| | | onExpenseCategoryChange, |
| | | applyTemplate, |
| | | onDetailAmountChange, |
| | | onApprovalFlowChange, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | syncApplyAmountFromDetails, |
| | | autoAssignApprovalFlow, |
| | | openFormDialog, |
| | | onFormClosed, |
| | | submitForm, |
| | | openDetail, |
| | | approvalActionLabel, |
| | | submitApprove, |
| | | handleExport, |
| | | handleImportClick, |
| | | onImportFile, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- å·®æ
æ¥éï¼å®¡æ¹æµç¨è¿åº¦å±ç¤º --> |
| | | <template> |
| | | <el-steps :active="activeStep" finish-status="success" align-center> |
| | | <el-step |
| | | v-for="(node, index) in sortedNodes" |
| | | :key="index" |
| | | :title="`èç¹ ${index + 1}`" |
| | | :description="stepDescription(node)" |
| | | :status="stepStatus(node, index)" |
| | | /> |
| | | </el-steps> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | |
| | | const props = defineProps({ |
| | | nodes: { type: Array, default: () => [] }, |
| | | currentIndex: { type: Number, default: 0 }, |
| | | }); |
| | | |
| | | const sortedNodes = computed(() => { |
| | | const list = props.nodes || []; |
| | | return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0)); |
| | | }); |
| | | |
| | | const activeStep = computed(() => { |
| | | const list = sortedNodes.value; |
| | | if (!list.length) return 0; |
| | | const finished = list.filter((n) => n.nodeStatus === "finish").length; |
| | | const hasError = list.some((n) => n.nodeStatus === "error"); |
| | | if (hasError) return Math.max(0, props.currentIndex); |
| | | return finished; |
| | | }); |
| | | |
| | | function stepDescription(node) { |
| | | const name = (node.approverName || "").trim() || "æªæå®"; |
| | | const opinion = (node.approveOpinion || "").trim(); |
| | | if (opinion) return `${name}ï¼${opinion}`; |
| | | return name; |
| | | } |
| | | |
| | | function stepStatus(node, index) { |
| | | if (node.nodeStatus === "error") return "error"; |
| | | if (node.nodeStatus === "finish") return "success"; |
| | | if (node.nodeStatus === "process" || index === props.currentIndex) return "process"; |
| | | return "wait"; |
| | | } |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- å·®æ
æ¥éï¼è¯¦æ
åªè¯»é¢æ¿ --> |
| | | <template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions-item label="æ¥éåå·">{{ row.reimburseNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç¶æ"> |
| | | <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="åå·¥ç¼å·">{{ row.employeeNo || row.applicantNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå·¥å§å">{{ row.employeeName || row.applicantName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¥éåå " :span="2">{{ row.reimburseReason || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åºå·®å¼å§">{{ row.travelStartTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åºå·®ç»æ">{{ row.travelEndTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="åºå·®å°">{{ row.departurePlace || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç®çå°">{{ row.destination || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="é
åºæ å">{{ row.hotelStandard != null ? `${row.hotelStandard} å
/æ` : "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ä½å®¿å¤©æ°">{{ row.hotelDays ?? "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="çæ´»è¡¥è´´">{{ row.livingSubsidy != null ? `${row.livingSubsidy} å
` : "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·éé¢">{{ row.applyAmount != null ? `${row.applyAmount} å
` : "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="æ¶æ¬¾äºº">{{ row.payee || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç¹æ¹"> |
| | | <el-tag :type="row.needSpecialApproval ? 'danger' : 'info'" size="small"> |
| | | {{ row.needSpecialApproval ? "è¶
æ¯éç¹æ¹" : "æ åå
" }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item v-if="row.rejectReason" label="驳ååå " :span="2"> |
| | | <span class="reject-text">{{ row.rejectReason }}</span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´" :span="2">{{ row.createTime || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-divider content-position="left">æ¥éæç»</el-divider> |
| | | <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small"> |
| | | <el-table-column type="index" label="åºå·" width="55" align="center" /> |
| | | <el-table-column prop="invoiceDate" label="åç¥¨æ¥æ" width="120" /> |
| | | <el-table-column label="è´¹ç¨ç§ç®" width="100"> |
| | | <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="amount" label="éé¢" width="100" /> |
| | | <el-table-column prop="description" label="æè¿°" min-width="140" show-overflow-tooltip /> |
| | | </el-table> |
| | | <el-empty v-else description="ææ æç»" :image-size="48" /> |
| | | |
| | | <el-divider content-position="left">å票éä»¶</el-divider> |
| | | <template v-if="attachmentFiles.length"> |
| | | <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)"> |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <el-empty v-else description="ææ éä»¶" :image-size="48" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { expenseSubjectLabel, statusLabel, statusTagType } from "../travelReimburseUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const attachmentFiles = computed(() => { |
| | | const list = props.row?.attachmentList?.length |
| | | ? props.row.attachmentList |
| | | : props.row?.invoiceAttachments; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | function openFile(f) { |
| | | const url = f?.url || f?.downloadURL || f?.previewURL; |
| | | if (url) window.open(url, "_blank"); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .reject-text { |
| | | color: var(--el-color-danger); |
| | | } |
| | | .file-tag { |
| | | margin: 0 8px 8px 0; |
| | | cursor: pointer; |
| | | } |
| | | </style> |
| | |
| | | <!-- |
| | | 模å䏿åï¼å·®æ
æ¥é |
| | | ç®å½æ è¯ï¼ReimburseManage/travel-reimburseï¼travel-reimburse â 䏿ï¼å·®æ
æ¥éï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <!--OA模åï¼å·®æ
æ¥é--> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">ç³è¯·äººï¼</span> |
| | | <el-input |
| | | v-model="searchForm.applicantKeyword" |
| | | style="width: 220px" |
| | | placeholder="å§åæç¼å·" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <span class="search_title" style="margin-left: 12px">åºå·®å¼å§ï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.travelStartFrom" |
| | | type="date" |
| | | placeholder="å¼å§æ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 150px" |
| | | clearable |
| | | /> |
| | | <span class="search_title" style="margin-left: 8px">ç»æï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.travelEndTo" |
| | | type="date" |
| | | placeholder="ç»ææ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 150px" |
| | | clearable |
| | | /> |
| | | <el-button type="primary" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div class="search_actions"> |
| | | <el-button type="success" plain @click="handleImportClick">导å
¥</el-button> |
| | | <el-button type="warning" plain @click="handleExport">导åº</el-button> |
| | | <el-button type="primary" @click="openFormDialog('add')">æ°å¢å·®æ
æ¥é</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" /> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | :total="page.total" |
| | | @pagination="pagination" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="1120px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="travel-reimburse-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-alert |
| | | v-if="budgetHint.visible" |
| | | :title="budgetHint.title" |
| | | :type="budgetHint.type" |
| | | :description="budgetHint.description" |
| | | show-icon |
| | | :closable="false" |
| | | class="mb16" |
| | | /> |
| | | <el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16"> |
| | | <template #title>å·®æ
æ åè¶
æ¯æéï¼éç¹æ¹ï¼</template> |
| | | <ul class="warn-list"> |
| | | <li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li> |
| | | </ul> |
| | | </el-alert> |
| | | |
| | | <el-form |
| | | ref="formRef" |
| | | :model="form" |
| | | :rules="formRules" |
| | | label-width="120px" |
| | | class="travel-reimburse-form" |
| | | :disabled="formDialog.readonly" |
| | | > |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">åºæ¬ä¿¡æ¯</span></template> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åå·¥ç¼å·"> |
| | | <el-input v-model="form.employeeNo" readonly placeholder="éæ©åå·¥åèªå¨å¸¦åº" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åå·¥å§å" prop="applicantId"> |
| | | <el-select |
| | | v-model="form.applicantId" |
| | | filterable |
| | | remote |
| | | clearable |
| | | reserve-keyword |
| | | placeholder="è¯·éæ©ææç´¢åå·¥" |
| | | style="width: 100%" |
| | | :remote-method="remoteSearchApplicantForm" |
| | | :loading="applicantFormSearchLoading" |
| | | @change="onApplicantChange" |
| | | > |
| | | <el-option |
| | | v-for="u in applicantFormOptions" |
| | | :key="u.userId" |
| | | :label="userSelectLabel(u)" |
| | | :value="u.userId" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="æ¥éåå " prop="reimburseReason"> |
| | | <el-input |
| | | v-model="form.reimburseReason" |
| | | type="textarea" |
| | | :rows="3" |
| | | placeholder="请填ååºå·®åæ¥éåå " |
| | | maxlength="2000" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åºå·®å¼å§" prop="travelStartTime"> |
| | | <el-date-picker |
| | | v-model="form.travelStartTime" |
| | | type="datetime" |
| | | placeholder="å¼å§æ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | @change="onTravelRangeChange" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åºå·®ç»æ" prop="travelEndTime"> |
| | | <el-date-picker |
| | | v-model="form.travelEndTime" |
| | | type="datetime" |
| | | placeholder="ç»ææ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | @change="onTravelRangeChange" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="åºå·®å¤©æ°"> |
| | | <el-input :model-value="travelDaysDisplay" readonly> |
| | | <template #append>天</template> |
| | | </el-input> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="åºå·®å°" prop="departurePlace"> |
| | | <el-input v-model="form.departurePlace" placeholder="åºååå¸" @blur="recalcTravelStandards" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="ç®çå°" prop="destination"> |
| | | <el-input v-model="form.destination" placeholder="ç®çåå¸" @blur="recalcTravelStandards" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header> |
| | | <div class="card-header-row"> |
| | | <span class="card-header-title">å·®æ
æ å</span> |
| | | <el-text type="info" size="small">{{ travelTierLabel }} · çæ´»è¡¥è´´å»ºè®® {{ suggestedLivingSubsidy }} å
</el-text> |
| | | </div> |
| | | </template> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="é
åºæ å"> |
| | | <el-input-number |
| | | v-model="form.hotelStandard" |
| | | :min="0" |
| | | :precision="2" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | @change="recalcTravelStandards" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="ä½å®¿å¤©æ°"> |
| | | <el-input-number |
| | | v-model="form.hotelDays" |
| | | :min="0" |
| | | :max="365" |
| | | :precision="0" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | @change="recalcTravelStandards" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="çæ´»è¡¥è´´"> |
| | | <el-input-number |
| | | v-model="form.livingSubsidy" |
| | | :min="0" |
| | | :precision="2" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | @change="recalcTravelStandards" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="交é补贴"> |
| | | <el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>å
</template></el-input> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="ä½å®¿éé¢"> |
| | | <el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>å
</template></el-input> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="ç¹æ¹æ è®°"> |
| | | <el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain"> |
| | | {{ form.needSpecialApproval ? "è¶
æ¯éç¹æ¹" : "卿 åèå´å
" }} |
| | | </el-tag> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">éé¢ä¸æ¶æ¬¾</span></template> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç³è¯·éé¢" prop="applyAmount"> |
| | | <div class="amount-row"> |
| | | <el-input-number v-model="form.applyAmount" :min="0" :precision="2" controls-position="right" class="amount-input" /> |
| | | <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails"> |
| | | ææç»æ±æ» {{ detailTotalAmount }} å
|
| | | </el-button> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ¶æ¬¾äºº" prop="payee"> |
| | | <el-input v-model="form.payee" placeholder="请è¾å
¥æ¶æ¬¾äºº" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header> |
| | | <div class="card-header-row"> |
| | | <span class="card-header-title">æ¥éæç»</span> |
| | | <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">æ°å¢æç»</el-button> |
| | | </div> |
| | | </template> |
| | | |
| | | <el-table :data="form.expenseDetails" border size="small" class="detail-table"> |
| | | <el-table-column type="index" label="åºå·" width="55" align="center" /> |
| | | <el-table-column label="åç¥¨æ¥æ" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-if="!formDialog.readonly" |
| | | v-model="row.invoiceDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | size="small" |
| | | style="width: 100%" |
| | | /> |
| | | <span v-else>{{ row.invoiceDate || "â" }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="è´¹ç¨ç§ç®" width="130"> |
| | | <template #default="{ row }"> |
| | | <el-select |
| | | v-if="!formDialog.readonly" |
| | | v-model="row.expenseSubject" |
| | | size="small" |
| | | style="width: 100%" |
| | | @change="recalcTravelStandards" |
| | | > |
| | | <el-option |
| | | v-for="opt in EXPENSE_SUBJECT_OPTIONS" |
| | | :key="opt.value" |
| | | :label="opt.label" |
| | | :value="opt.value" |
| | | /> |
| | | </el-select> |
| | | <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="éé¢" width="120"> |
| | | <template #default="{ row }"> |
| | | <el-input-number |
| | | v-if="!formDialog.readonly" |
| | | v-model="row.amount" |
| | | :min="0" |
| | | :precision="2" |
| | | size="small" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | @change="onDetailAmountChange" |
| | | /> |
| | | <span v-else>{{ row.amount ?? "â" }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿°" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说æ" /> |
| | | <span v-else>{{ row.description || "â" }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column v-if="!formDialog.readonly" label="æä½" width="70" align="center"> |
| | | <template #default="{ $index }"> |
| | | <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">å é¤</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">éä»¶ï¼å票ï¼</span></template> |
| | | <el-form-item label-width="0" class="attachment-form-item"> |
| | | <div class="upload-block"> |
| | | <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="ç¹å»éæ©æä»¶" /> |
| | | </div> |
| | | </el-form-item> |
| | | </el-card> |
| | | |
| | | <el-card class="form-section" shadow="never"> |
| | | <template #header><span class="card-header-title">å®¡æ¹æµç¨</span></template> |
| | | <el-form-item prop="approvalFlowNodes" label-width="0"> |
| | | <ApprovalFlowEditor |
| | | v-if="!formDialog.readonly" |
| | | v-model="form.approvalFlowNodes" |
| | | :user-options="flowUserOptions" |
| | | @update:model-value="onApprovalFlowChange" |
| | | /> |
| | | <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" /> |
| | | <p v-if="!formDialog.readonly" class="flow-tip">è³å°ä¿çä¸ä¸ªèç¹ï¼å®¡æ ¸ä¸ãå·²éè¿çåæ®ä¸å¯ç¼è¾ã</p> |
| | | </el-form-item> |
| | | </el-card> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">æ 交</el-button> |
| | | <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "å
³ é" : "å æ¶" }}</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="å·®æ
æ¥é详æ
" width="900px" append-to-body destroy-on-close> |
| | | <DetailPanel :row="detailRow" /> |
| | | <ApprovalFlowProgress |
| | | class="mt16" |
| | | :nodes="detailRow.approvalFlowNodes" |
| | | :current-index="detailRow.currentNodeIndex ?? 0" |
| | | /> |
| | | <el-divider content-position="left">审æ¹è®°å½ï¼å
¨æµç¨ççï¼</el-divider> |
| | | <el-timeline v-if="detailRow.approvalRecords?.length"> |
| | | <el-timeline-item |
| | | v-for="(rec, i) in detailRow.approvalRecords" |
| | | :key="i" |
| | | :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'" |
| | | :timestamp="rec.time" |
| | | > |
| | | {{ rec.operatorName }} â {{ approvalActionLabel(rec.result) }}ï¼{{ rec.opinion || "æ æè§" }} |
| | | </el-timeline-item> |
| | | </el-timeline> |
| | | <el-empty v-else description="ææ å®¡æ¹è®°å½" :image-size="60" /> |
| | | <template #footer> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- å®¡æ¹ --> |
| | | <el-dialog |
| | | v-model="approveDialog.visible" |
| | | title="å·®æ
æ¥é审æ¹" |
| | | width="1000px" |
| | | append-to-body |
| | | destroy-on-close |
| | | @closed="approveOpinion = ''" |
| | | > |
| | | <DetailPanel :row="approveDialog.row" /> |
| | | <el-divider content-position="left">æµç¨è¿åº¦</el-divider> |
| | | <ApprovalFlowProgress |
| | | :nodes="approveDialog.row?.approvalFlowNodes" |
| | | :current-index="approveDialog.row?.currentNodeIndex ?? 0" |
| | | /> |
| | | <el-form label-width="100px" class="mt16"> |
| | | <el-form-item label="å®¡æ¹æè§"> |
| | | <el-input |
| | | v-model="approveOpinion" |
| | | type="textarea" |
| | | :rows="3" |
| | | maxlength="500" |
| | | show-word-limit |
| | | placeholder="éè¿å¯ç空ï¼é©³å请填ååå " |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button type="success" @click="submitApprove('approved')">é è¿</el-button> |
| | | <el-button type="danger" @click="submitApprove('rejected')">驳 å</el-button> |
| | | <el-button @click="approveDialog.visible = false">å æ¶</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue' |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue"; |
| | | import ApprovalFlowProgress from "./components/ApprovalFlowProgress.vue"; |
| | | import DetailPanel from "./components/DetailPanel.vue"; |
| | | import { useTravelReimburse } from "./useTravelReimburse.js"; |
| | | |
| | | const tr = useTravelReimburse(); |
| | | const { |
| | | Search, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseSubjectLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | importInputRef, |
| | | formRef, |
| | | form, |
| | | formDialog, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | approveDialog, |
| | | approveOpinion, |
| | | applicantFormSearchLoading, |
| | | applicantFormOptions, |
| | | flowUserOptions, |
| | | travelDaysDisplay, |
| | | travelTierLabel, |
| | | suggestedLivingSubsidy, |
| | | suggestedTransportSubsidy, |
| | | suggestedHotelLimit, |
| | | detailTotalAmount, |
| | | overBudgetWarnings, |
| | | budgetHint, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | remoteSearchApplicantForm, |
| | | userSelectLabel, |
| | | onApplicantChange, |
| | | recalcTravelStandards, |
| | | onTravelRangeChange, |
| | | onDetailAmountChange, |
| | | onApprovalFlowChange, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | syncApplyAmountFromDetails, |
| | | openFormDialog, |
| | | onFormClosed, |
| | | submitForm, |
| | | openDetail, |
| | | approvalActionLabel, |
| | | submitApprove, |
| | | handleExport, |
| | | handleImportClick, |
| | | onImportFile, |
| | | } = tr; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .mb16 { |
| | | margin-bottom: 16px; |
| | | } |
| | | .mb8 { |
| | | margin-bottom: 8px; |
| | | } |
| | | .mt16 { |
| | | margin-top: 16px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_actions { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | } |
| | | .search_title { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-regular); |
| | | } |
| | | .sr-only-input { |
| | | position: absolute; |
| | | width: 1px; |
| | | height: 1px; |
| | | padding: 0; |
| | | margin: -1px; |
| | | overflow: hidden; |
| | | clip: rect(0, 0, 0, 0); |
| | | white-space: nowrap; |
| | | border: 0; |
| | | } |
| | | .form-section { |
| | | margin-bottom: 16px; |
| | | border: 1px solid var(--el-border-color-lighter); |
| | | } |
| | | .form-section :deep(.el-card__header) { |
| | | padding: 12px 16px; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .form-section :deep(.el-card__body) { |
| | | padding: 16px 16px 4px; |
| | | } |
| | | .card-header-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | } |
| | | .card-header-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .amount-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | width: 100%; |
| | | } |
| | | .amount-input { |
| | | flex: 1; |
| | | min-width: 160px; |
| | | } |
| | | .w-full { |
| | | width: 100%; |
| | | } |
| | | .attachment-form-item { |
| | | margin-bottom: 0; |
| | | } |
| | | .detail-table { |
| | | margin-bottom: 0; |
| | | } |
| | | .section-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | margin: 8px 0 12px; |
| | | color: var(--el-text-color-primary); |
| | | border-left: 3px solid var(--el-color-primary); |
| | | padding-left: 8px; |
| | | } |
| | | .field-tip { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | margin-top: 4px; |
| | | } |
| | | .warn-list { |
| | | margin: 0; |
| | | padding-left: 18px; |
| | | } |
| | | .detail-toolbar { |
| | | margin-bottom: 8px; |
| | | } |
| | | .upload-block { |
| | | width: 100%; |
| | | } |
| | | .flow-tip { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | margin-top: 8px; |
| | | } |
| | | .sync-btn { |
| | | margin-top: 4px; |
| | | } |
| | | .travel-reimburse-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | .travel-reimburse-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .travel-reimburse-form :deep(.el-input-number) { |
| | | width: 100%; |
| | | } |
| | | .travel-reimburse-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | /** è´¹ç¨ç§ç® */ |
| | | export const EXPENSE_SUBJECT_OPTIONS = [ |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "ä½å®¿è´¹", value: "hotel" }, |
| | | { label: "é¤é¥®è´¹", value: "meal" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | const TIER1_CITIES = ["å京", "䏿µ·", "广å·", "æ·±å³"]; |
| | | |
| | | export function expenseSubjectLabel(v) { |
| | | return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function statusLabel(v) { |
| | | if (v === "approved") return "éè¿"; |
| | | if (v === "rejected") return "驳å"; |
| | | return "å®¡æ ¸ä¸"; |
| | | } |
| | | |
| | | export function statusTagType(v) { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | return "warning"; |
| | | } |
| | | |
| | | export function detectTravelTier(destination) { |
| | | const city = (destination || "").trim(); |
| | | if (!city) return "tier3"; |
| | | if (TIER1_CITIES.some((c) => city.includes(c))) return "tier1"; |
| | | const tier2Keywords = ["æå·", "å京", "æ¦æ±", "æé½", "éåº", "西å®", "天津", "èå·", "é¿æ²", "éå·"]; |
| | | if (tier2Keywords.some((c) => city.includes(c))) return "tier2"; |
| | | return "tier3"; |
| | | } |
| | | |
| | | export function getTravelStandardByTier(tier) { |
| | | const map = { |
| | | tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "ä¸çº¿åå¸" }, |
| | | tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "äºçº¿åå¸" }, |
| | | tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "å
¶ä»åå¸" }, |
| | | }; |
| | | return map[tier] || map.tier3; |
| | | } |
| | | |
| | | export function computeTravelDays(startStr, endStr) { |
| | | if (!startStr || !endStr) return null; |
| | | const t0 = dayjs(startStr); |
| | | const t1 = dayjs(endStr); |
| | | if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null; |
| | | const days = Math.ceil(t1.diff(t0, "day", true)); |
| | | return Math.max(1, days); |
| | | } |
| | | |
| | | export function createEmptyExpenseDetail() { |
| | | return { |
| | | id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: undefined, |
| | | description: "", |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyForm() { |
| | | return { |
| | | id: undefined, |
| | | reimburseNo: "", |
| | | applicantId: "", |
| | | employeeNo: "", |
| | | employeeName: "", |
| | | reimburseReason: "", |
| | | travelStartTime: "", |
| | | travelEndTime: "", |
| | | travelDays: undefined, |
| | | departurePlace: "", |
| | | destination: "", |
| | | hotelStandard: undefined, |
| | | hotelDays: undefined, |
| | | livingSubsidy: undefined, |
| | | applyAmount: undefined, |
| | | payee: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [], |
| | | currentNodeIndex: 0, |
| | | needSpecialApproval: false, |
| | | deptId: "", |
| | | deptName: "", |
| | | travelTier: "tier3", |
| | | }; |
| | | } |
| | | |
| | | export function initApprovalFlowNodes(nodes) { |
| | | return (nodes || []).map((n, i) => ({ |
| | | ...n, |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | nodeStatus: i === 0 ? "process" : "wait", |
| | | approveOpinion: n.approveOpinion || "", |
| | | approveTime: n.approveTime || "", |
| | | })); |
| | | } |
| | | |
| | | export function advanceApprovalFlow(row, opinion) { |
| | | const nodes = [...(row.approvalFlowNodes || [])]; |
| | | const idx = row.currentNodeIndex ?? 0; |
| | | if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult }; |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | nodes[idx] = { |
| | | ...nodes[idx], |
| | | nodeStatus: "finish", |
| | | approveOpinion: opinion || "åæ", |
| | | approveTime: now, |
| | | }; |
| | | const next = idx + 1; |
| | | if (next >= nodes.length) { |
| | | return { nodes, currentNodeIndex: idx, approvalResult: "approved" }; |
| | | } |
| | | nodes[next] = { ...nodes[next], nodeStatus: "process" }; |
| | | return { nodes, currentNodeIndex: next, approvalResult: "pending" }; |
| | | } |
| | | |
| | | export function rejectApprovalFlow(row, opinion) { |
| | | const nodes = [...(row.approvalFlowNodes || [])]; |
| | | const idx = row.currentNodeIndex ?? 0; |
| | | const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); |
| | | if (nodes[idx]) { |
| | | nodes[idx] = { |
| | | ...nodes[idx], |
| | | nodeStatus: "error", |
| | | approveOpinion: opinion || "驳å", |
| | | approveTime: now, |
| | | }; |
| | | } |
| | | return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: opinion || "驳å" }; |
| | | } |
| | | |
| | | /** 模æé¨é¨é¢ç®ï¼ä¸é¢ç®ç³»ç»èå¨å ä½ï¼ */ |
| | | export function mockDeptBudget(deptId) { |
| | | const id = String(deptId || "default"); |
| | | let s = 0; |
| | | for (let i = 0; i < id.length; i++) s += id.charCodeAt(i); |
| | | const total = 500000 + (s % 200) * 1000; |
| | | const used = (s % 80) * 3500; |
| | | return { |
| | | deptId: id, |
| | | totalBudget: total, |
| | | usedAmount: used, |
| | | remainingAmount: Math.max(0, total - used), |
| | | }; |
| | | } |
| | | |
| | | export function normalizeImportedRow(raw, idx) { |
| | | const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`; |
| | | const travelDays = |
| | | raw.travelDays != null |
| | | ? Number(raw.travelDays) |
| | | : computeTravelDays(raw.travelStartTime, raw.travelEndTime); |
| | | return { |
| | | id, |
| | | reimburseNo: raw.reimburseNo || `TR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`, |
| | | applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`, |
| | | employeeNo: raw.employeeNo ?? raw.applicantNo ?? "", |
| | | employeeName: raw.employeeName ?? raw.applicantName ?? "æªç¥", |
| | | applicantNo: raw.employeeNo ?? raw.applicantNo ?? "", |
| | | applicantName: raw.employeeName ?? raw.applicantName ?? "æªç¥", |
| | | reimburseReason: raw.reimburseReason ?? "", |
| | | travelStartTime: raw.travelStartTime ?? "", |
| | | travelEndTime: raw.travelEndTime ?? "", |
| | | travelDays: travelDays == null || Number.isNaN(travelDays) ? 1 : travelDays, |
| | | departurePlace: raw.departurePlace ?? "", |
| | | destination: raw.destination ?? "", |
| | | hotelStandard: raw.hotelStandard, |
| | | hotelDays: raw.hotelDays, |
| | | livingSubsidy: raw.livingSubsidy, |
| | | applyAmount: raw.applyAmount ?? 0, |
| | | payee: raw.payee ?? "", |
| | | expenseDetails: Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [], |
| | | invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [], |
| | | approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) ? raw.approvalFlowNodes : [], |
| | | currentNodeIndex: raw.currentNodeIndex ?? 0, |
| | | approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending", |
| | | rejectReason: raw.rejectReason ?? "", |
| | | approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [], |
| | | needSpecialApproval: !!raw.needSpecialApproval, |
| | | deptId: raw.deptId ?? "", |
| | | deptName: raw.deptName ?? "", |
| | | travelTier: raw.travelTier || detectTravelTier(raw.destination), |
| | | createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | import { |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseSubjectLabel, |
| | | statusLabel, |
| | | statusTagType, |
| | | detectTravelTier, |
| | | getTravelStandardByTier, |
| | | computeTravelDays, |
| | | createEmptyExpenseDetail, |
| | | createEmptyForm, |
| | | initApprovalFlowNodes, |
| | | advanceApprovalFlow, |
| | | rejectApprovalFlow, |
| | | mockDeptBudget, |
| | | normalizeImportedRow, |
| | | } from "./travelReimburseUtils.js"; |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload?.data && Array.isArray(payload.data)) return payload.data; |
| | | if (payload?.rows && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | function demoFlowNodes(names = ["é¨é¨ä¸»ç®¡", "è´¢å¡å®¡æ ¸"]) { |
| | | return names.map((name, i) => ({ |
| | | approverId: `mock_${i + 1}`, |
| | | approverName: name, |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | nodeStatus: i === 0 ? "process" : "wait", |
| | | approveOpinion: "", |
| | | approveTime: "", |
| | | })); |
| | | } |
| | | |
| | | export function useTravelReimburse() { |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | reimburseNo: "TR202605090001", |
| | | applicantId: "mock_1", |
| | | employeeNo: "zhangsan", |
| | | employeeName: "å¼ ä¸", |
| | | applicantNo: "zhangsan", |
| | | applicantName: "å¼ ä¸", |
| | | reimburseReason: "èµ´ä¸æµ·åå è¡ä¸å±ä¼åå®¢æ·æè®¿ã", |
| | | travelStartTime: "2026-05-10 08:00:00", |
| | | travelEndTime: "2026-05-13 18:00:00", |
| | | travelDays: 4, |
| | | departurePlace: "æå·", |
| | | destination: "䏿µ·", |
| | | hotelStandard: 600, |
| | | hotelDays: 3, |
| | | livingSubsidy: 400, |
| | | applyAmount: 4580, |
| | | payee: "å¼ ä¸", |
| | | expenseDetails: [ |
| | | { id: "d1", invoiceDate: "2026-05-10", expenseSubject: "transport", amount: 553, description: "é«éå¾è¿" }, |
| | | { id: "d2", invoiceDate: "2026-05-11", expenseSubject: "hotel", amount: 1680, description: "é
åºä½å®¿" }, |
| | | ], |
| | | attachmentList: [{ name: "é«é票.pdf", url: "/mock/invoice1.pdf" }], |
| | | invoiceAttachments: [{ name: "é«é票.pdf", url: "/mock/invoice1.pdf" }], |
| | | approvalFlowNodes: demoFlowNodes(), |
| | | currentNodeIndex: 0, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | approvalRecords: [], |
| | | needSpecialApproval: false, |
| | | deptId: "101", |
| | | deptName: "éå®é¨", |
| | | travelTier: "tier1", |
| | | createTime: "2026-05-09 10:20:00", |
| | | }, |
| | | { |
| | | id: "2", |
| | | reimburseNo: "TR202605080002", |
| | | applicantId: "mock_2", |
| | | employeeNo: "lisi", |
| | | employeeName: "æå", |
| | | applicantNo: "lisi", |
| | | applicantName: "æå", |
| | | reimburseReason: "æé½åå
¬å¸ææ¯æ¯æã", |
| | | travelStartTime: "2026-05-05 09:00:00", |
| | | travelEndTime: "2026-05-07 17:00:00", |
| | | travelDays: 3, |
| | | departurePlace: "æ¦æ±", |
| | | destination: "æé½", |
| | | hotelStandard: 450, |
| | | hotelDays: 2, |
| | | livingSubsidy: 240, |
| | | applyAmount: 2100, |
| | | payee: "æå", |
| | | expenseDetails: [{ id: "d3", invoiceDate: "2026-05-06", expenseSubject: "meal", amount: 180, description: "å·¥ä½é¤" }], |
| | | attachmentList: [], |
| | | invoiceAttachments: [], |
| | | approvalFlowNodes: demoFlowNodes().map((n, i) => ({ ...n, nodeStatus: "finish", approveOpinion: "åæ", approveTime: "2026-05-08 11:00:00" })), |
| | | currentNodeIndex: 1, |
| | | approvalResult: "approved", |
| | | rejectReason: "", |
| | | approvalRecords: [{ operatorName: "é¨é¨ä¸»ç®¡", result: "approved", opinion: "åæ", time: "2026-05-08 10:00:00" }], |
| | | needSpecialApproval: false, |
| | | deptId: "102", |
| | | deptName: "ææ¯é¨", |
| | | travelTier: "tier2", |
| | | createTime: "2026-05-07 16:00:00", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ applicantKeyword: "", travelStartFrom: "", travelEndTo: "" }); |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | const importInputRef = ref(null); |
| | | const allUsersCache = ref([]); |
| | | const applicantFormSearchLoading = ref(false); |
| | | const applicantFormOptions = ref([]); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false }); |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | const approveDialog = reactive({ visible: false, row: null }); |
| | | const approveOpinion = ref(""); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | const kw = (searchForm.applicantKeyword || "").trim().toLowerCase(); |
| | | if (kw) { |
| | | list = list.filter((r) => { |
| | | const name = (r.applicantName || r.employeeName || "").toLowerCase(); |
| | | const no = (r.applicantNo || r.employeeNo || "").toLowerCase(); |
| | | return name.includes(kw) || no.includes(kw); |
| | | }); |
| | | } |
| | | if (searchForm.travelStartFrom) { |
| | | list = list.filter((r) => !r.travelStartTime || r.travelStartTime.slice(0, 10) >= searchForm.travelStartFrom); |
| | | } |
| | | if (searchForm.travelEndTo) { |
| | | list = list.filter((r) => !r.travelEndTime || r.travelEndTime.slice(0, 10) <= searchForm.travelEndTo); |
| | | } |
| | | return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1)); |
| | | }); |
| | | |
| | | watch( |
| | | filteredList, |
| | | (list) => { |
| | | page.total = list.length; |
| | | const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1); |
| | | if (page.current > maxPage) page.current = maxPage; |
| | | }, |
| | | { immediate: true } |
| | | ); |
| | | |
| | | const tableData = computed(() => { |
| | | const start = (page.current - 1) * page.size; |
| | | return filteredList.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser)); |
| | | |
| | | const travelDaysDisplay = computed(() => { |
| | | const d = computeTravelDays(form.travelStartTime, form.travelEndTime); |
| | | return d == null ? "" : String(d); |
| | | }); |
| | | |
| | | const travelTierLabel = computed(() => { |
| | | const std = getTravelStandardByTier(form.travelTier || detectTravelTier(form.destination)); |
| | | return `æ${std.label}æ å`; |
| | | }); |
| | | |
| | | const suggestedLivingSubsidy = computed(() => { |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || form.hotelDays || 0; |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | return Math.round(std.mealPerDay * days * 100) / 100; |
| | | }); |
| | | |
| | | const suggestedTransportSubsidy = computed(() => { |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || 0; |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | return Math.round(std.transportPerDay * days * 100) / 100; |
| | | }); |
| | | |
| | | const suggestedHotelLimit = computed(() => { |
| | | const nights = form.hotelDays || 0; |
| | | const perNight = form.hotelStandard ?? getTravelStandardByTier(form.travelTier).hotelPerNight; |
| | | return Math.round(perNight * nights * 100) / 100; |
| | | }); |
| | | |
| | | const detailTotalAmount = computed(() => { |
| | | const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | return Math.round(sum * 100) / 100; |
| | | }); |
| | | |
| | | const overBudgetWarnings = computed(() => buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value)); |
| | | |
| | | const budgetHint = computed(() => { |
| | | if (!form.deptId) return { visible: false }; |
| | | const b = mockDeptBudget(form.deptId); |
| | | const apply = Number(form.applyAmount) || detailTotalAmount.value || 0; |
| | | const after = b.remainingAmount - apply; |
| | | return { |
| | | visible: true, |
| | | type: after < 0 ? "error" : "info", |
| | | title: `é¨é¨é¢ç®èå¨ï¼${form.deptName || b.deptId}ï¼`, |
| | | description: `年度é¢ç® ${b.totalBudget} å
ï¼å·²ç¨ ${b.usedAmount} å
ï¼å©ä½ ${b.remainingAmount} å
ï¼æ¬åç³è¯·åé¢è®¡å©ä½ ${Math.round(after * 100) / 100} å
ã`, |
| | | }; |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "æ¥éåå·", prop: "reimburseNo", width: 150 }, |
| | | { label: "ç³è¯·äººç¼å·", prop: "applicantNo", width: 110 }, |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 90 }, |
| | | { label: "åºå·®å¼å§", prop: "travelStartTime", width: 165 }, |
| | | { label: "åºå·®ç»æ", prop: "travelEndTime", width: 165 }, |
| | | { label: "å建æ¶é´", prop: "createTime", width: 165 }, |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "approvalResult", |
| | | width: 100, |
| | | dataType: "tag", |
| | | formatData: (v) => statusLabel(v), |
| | | formatType: (v) => statusTagType(v), |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 200, |
| | | operation: [ |
| | | { name: "ç¼è¾", type: "text", disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", clickFun: (row) => openFormDialog("edit", row) }, |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | { name: "审æ¹", type: "text", disabled: (row) => row.approvalResult !== "pending", clickFun: (row) => openApprove(row) }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formRules = { |
| | | applicantId: [{ required: true, message: "è¯·éæ©åå·¥", trigger: "change" }], |
| | | reimburseReason: [{ required: true, message: "è¯·å¡«åæ¥éåå ", trigger: "blur" }], |
| | | travelStartTime: [{ required: true, message: "è¯·éæ©åºå·®å¼å§æ¶é´", trigger: "change" }], |
| | | travelEndTime: [ |
| | | { required: true, message: "è¯·éæ©åºå·®ç»ææ¶é´", trigger: "change" }, |
| | | { |
| | | validator: (_r, val, cb) => { |
| | | if (!form.travelStartTime || !val) { cb(); return; } |
| | | if (computeTravelDays(form.travelStartTime, val) == null) cb(new Error("ç»ææ¶é´é¡»æäºå¼å§æ¶é´")); |
| | | else cb(); |
| | | }, |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | departurePlace: [{ required: true, message: "请填ååºå·®å°", trigger: "blur" }], |
| | | destination: [{ required: true, message: "请填åç®çå°", trigger: "blur" }], |
| | | applyAmount: [{ required: true, message: "请填åç³è¯·éé¢", trigger: "blur" }], |
| | | payee: [{ required: true, message: "è¯·å¡«åæ¶æ¬¾äºº", trigger: "blur" }], |
| | | approvalFlowNodes: [ |
| | | { |
| | | validator: (_r, _v, cb) => { |
| | | const nodes = form.approvalFlowNodes || []; |
| | | if (!nodes.length) { cb(new Error("请è³å°é
ç½®ä¸ä¸ªå®¡æ¹èç¹")); return; } |
| | | if (nodes.some((n) => n.approverId == null || n.approverId === "")) { cb(new Error("æ¯ä¸ªèç¹é¡»éæ©å®¡æ¹äºº")); return; } |
| | | cb(); |
| | | }, |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | function buildOverBudgetWarnings(f, detailTotal, hotelLimit, transportLimit, mealLimit) { |
| | | const warnings = []; |
| | | const bySubject = { transport: 0, hotel: 0, meal: 0, other: 0 }; |
| | | (f.expenseDetails || []).forEach((d) => { |
| | | const key = d.expenseSubject || "other"; |
| | | bySubject[key] = (bySubject[key] || 0) + (Number(d.amount) || 0); |
| | | }); |
| | | if (bySubject.transport > transportLimit && transportLimit > 0) { |
| | | warnings.push(`交éè´¹ ${bySubject.transport} å
è¶
åºæ å ${transportLimit} å
`); |
| | | } |
| | | if (bySubject.hotel > hotelLimit && hotelLimit > 0) { |
| | | warnings.push(`ä½å®¿è´¹ ${bySubject.hotel} å
è¶
åºéé¢ ${hotelLimit} å
`); |
| | | } |
| | | if (bySubject.meal > mealLimit && mealLimit > 0) { |
| | | warnings.push(`é¤é¥®è´¹ ${bySubject.meal} å
è¶
åºç活补贴建议 ${mealLimit} å
`); |
| | | } |
| | | const std = getTravelStandardByTier(f.travelTier); |
| | | if (f.hotelStandard > std.hotelPerNight) { |
| | | warnings.push(`é
åºæ å ${f.hotelStandard} å
/æé«äº${std.label}æ å ${std.hotelPerNight} å
/æ`); |
| | | } |
| | | const apply = Number(f.applyAmount) || detailTotal; |
| | | const standardTotal = transportLimit + hotelLimit + mealLimit; |
| | | if (apply > standardTotal && standardTotal > 0) { |
| | | warnings.push(`ç³è¯·æ»é¢ ${apply} å
é«äºå·®æ
æ åå计约 ${standardTotal} å
`); |
| | | } |
| | | return warnings; |
| | | } |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | allUsersCache.value = unwrapArray(await userListNoPageByTenantId()); |
| | | } catch { |
| | | allUsersCache.value = []; |
| | | } |
| | | } |
| | | |
| | | function userSelectLabel(u) { |
| | | const nick = u.nickName || ""; |
| | | const name = u.userName || ""; |
| | | if (nick && name && nick !== name) return `${nick}ï¼${name}ï¼`; |
| | | return nick || name || `ç¨æ·${u.userId ?? u.id ?? ""}`; |
| | | } |
| | | |
| | | function userById(id) { |
| | | return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function employeeNoFromUser(u) { |
| | | if (!u) return ""; |
| | | return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : ""); |
| | | } |
| | | |
| | | function filterUsersByQuery(query) { |
| | | const list = allUsersCache.value.filter(isActiveUser); |
| | | const q = (query || "").trim().toLowerCase(); |
| | | if (!q) return [...list]; |
| | | return list.filter((u) => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const uname = (u.userName || "").toLowerCase(); |
| | | return nick.includes(q) || uname.includes(q); |
| | | }); |
| | | } |
| | | |
| | | async function remoteSearchApplicantForm(query) { |
| | | applicantFormSearchLoading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | applicantFormOptions.value = filterUsersByQuery(query); |
| | | } finally { |
| | | applicantFormSearchLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | function onApplicantChange(uid) { |
| | | const u = userById(uid); |
| | | if (u) { |
| | | form.employeeName = u.nickName || u.userName || ""; |
| | | form.employeeNo = employeeNoFromUser(u); |
| | | form.payee = form.payee || form.employeeName; |
| | | form.deptId = String(u.deptId ?? u.sysDeptId ?? ""); |
| | | form.deptName = u.dept?.deptName ?? u.deptName ?? ""; |
| | | } else { |
| | | form.employeeName = ""; |
| | | form.employeeNo = ""; |
| | | } |
| | | } |
| | | |
| | | function recalcTravelStandards() { |
| | | form.travelTier = detectTravelTier(form.destination); |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | if (form.hotelStandard == null || form.hotelStandard === 0) form.hotelStandard = std.hotelPerNight; |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime); |
| | | if (days != null) { |
| | | form.travelDays = days; |
| | | if (form.hotelDays == null) form.hotelDays = Math.max(0, days - 1); |
| | | if (form.livingSubsidy == null || form.livingSubsidy === 0) form.livingSubsidy = suggestedLivingSubsidy.value; |
| | | } |
| | | form.needSpecialApproval = buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value).length > 0; |
| | | } |
| | | |
| | | function onTravelRangeChange() { |
| | | recalcTravelStandards(); |
| | | nextTick(() => formRef.value?.validateField?.("travelEndTime")); |
| | | } |
| | | |
| | | function onDetailAmountChange() { |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function onApprovalFlowChange() { |
| | | nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); |
| | | } |
| | | |
| | | function addExpenseDetail() { |
| | | form.expenseDetails.push(createEmptyExpenseDetail()); |
| | | } |
| | | |
| | | function removeExpenseDetail(index) { |
| | | form.expenseDetails.splice(index, 1); |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function mapAttachmentList(list) { |
| | | return (list || []).map((f, i) => ({ |
| | | id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`, |
| | | name: f.name || f.fileName || f.originalFilename || "æªå½å", |
| | | url: f.url || f.downloadURL || f.previewURL || f.previewUrl || "", |
| | | })); |
| | | } |
| | | |
| | | function syncApplyAmountFromDetails() { |
| | | form.applyAmount = detailTotalAmount.value; |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { tableLoading.value = false; }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.applicantKeyword = ""; |
| | | searchForm.travelStartFrom = ""; |
| | | searchForm.travelEndTo = ""; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openApprove(row) { |
| | | approveDialog.row = { ...row }; |
| | | approveDialog.visible = true; |
| | | } |
| | | |
| | | function approvalActionLabel(v) { |
| | | if (v === "approved") return "éè¿"; |
| | | if (v === "rejected") return "驳å"; |
| | | return "æäº¤"; |
| | | } |
| | | |
| | | async function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.readonly = false; |
| | | formDialog.title = mode === "add" ? "æ°å¢å·®æ
æ¥é" : "ç¼è¾å·®æ
æ¥é"; |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | Object.assign(form, createEmptyForm()); |
| | | if (mode === "edit" && row) { |
| | | Object.assign(form, { |
| | | ...JSON.parse(JSON.stringify(row)), |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])), |
| | | approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])), |
| | | expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])), |
| | | }); |
| | | const u = userById(row.applicantId); |
| | | applicantFormOptions.value = u ? [u] : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }]; |
| | | } else { |
| | | form.approvalFlowNodes = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }]; |
| | | remoteSearchApplicantForm(""); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => { |
| | | formRef.value?.clearValidate?.(); |
| | | recalcTravelStandards(); |
| | | }); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | if (!(form.expenseDetails || []).length) { |
| | | proxy?.$modal?.msgWarning?.("请è³å°æ·»å 䏿¡æ¥éæç»"); |
| | | return; |
| | | } |
| | | recalcTravelStandards(); |
| | | if (form.needSpecialApproval) { |
| | | try { |
| | | await proxy.$modal.confirm("åå¨è¶
æ¯é¡¹ï¼æäº¤åå°æ 记为éç¹æ¹ï¼æ¯å¦ç»§ç»ï¼"); |
| | | } catch { |
| | | return; |
| | | } |
| | | } |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime); |
| | | const payload = { |
| | | reimburseNo: form.reimburseNo || `TR${dayjs().format("YYYYMMDDHHmmss")}`, |
| | | applicantId: form.applicantId, |
| | | employeeNo: form.employeeNo, |
| | | employeeName: form.employeeName, |
| | | applicantNo: form.employeeNo, |
| | | applicantName: form.employeeName, |
| | | reimburseReason: form.reimburseReason, |
| | | travelStartTime: form.travelStartTime, |
| | | travelEndTime: form.travelEndTime, |
| | | travelDays: days, |
| | | departurePlace: form.departurePlace, |
| | | destination: form.destination, |
| | | hotelStandard: form.hotelStandard, |
| | | hotelDays: form.hotelDays, |
| | | livingSubsidy: form.livingSubsidy, |
| | | applyAmount: form.applyAmount, |
| | | payee: form.payee, |
| | | expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)), |
| | | attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])), |
| | | invoiceAttachments: mapAttachmentList(form.attachmentList), |
| | | approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes), |
| | | currentNodeIndex: 0, |
| | | needSpecialApproval: form.needSpecialApproval, |
| | | deptId: form.deptId, |
| | | deptName: form.deptName, |
| | | travelTier: form.travelTier, |
| | | }; |
| | | if (formDialog.mode === "add") { |
| | | allRows.value.unshift({ |
| | | id: `local_${Date.now()}`, |
| | | ...payload, |
| | | approvalResult: "pending", |
| | | rejectReason: "", |
| | | approvalRecords: [], |
| | | createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }); |
| | | proxy?.$modal?.msgSuccess?.("æäº¤æåï¼å·²è¿å
¥å®¡æ¹ï¼æ¬å°æ¨¡æï¼"); |
| | | } else { |
| | | const idx = allRows.value.findIndex((r) => r.id === form.id); |
| | | if (idx !== -1) { |
| | | const prev = allRows.value[idx]; |
| | | allRows.value[idx] = { |
| | | ...prev, |
| | | ...payload, |
| | | id: form.id, |
| | | approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult, |
| | | approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes), |
| | | currentNodeIndex: 0, |
| | | createTime: prev.createTime, |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | async function submitApprove(result) { |
| | | const row = approveDialog.row; |
| | | if (!row) return; |
| | | if (result === "rejected" && !(approveOpinion.value || "").trim()) { |
| | | proxy?.$modal?.msgWarning?.("驳å须填åå®¡æ¹æè§"); |
| | | return; |
| | | } |
| | | const idx = allRows.value.findIndex((r) => r.id === row.id); |
| | | if (idx === -1) return; |
| | | const cur = allRows.value[idx]; |
| | | const operatorName = "å½å审æ¹äºº"; |
| | | const record = { |
| | | operatorName, |
| | | result, |
| | | opinion: approveOpinion.value || (result === "approved" ? "åæ" : "驳å"), |
| | | time: dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }; |
| | | const records = [...(cur.approvalRecords || []), record]; |
| | | let flowUpdate; |
| | | if (result === "approved") { |
| | | flowUpdate = advanceApprovalFlow(cur, approveOpinion.value); |
| | | } else { |
| | | flowUpdate = rejectApprovalFlow(cur, approveOpinion.value); |
| | | } |
| | | allRows.value[idx] = { |
| | | ...cur, |
| | | approvalFlowNodes: flowUpdate.nodes, |
| | | currentNodeIndex: flowUpdate.currentNodeIndex, |
| | | approvalResult: flowUpdate.approvalResult, |
| | | rejectReason: flowUpdate.rejectReason ?? cur.rejectReason, |
| | | approvalRecords: records, |
| | | }; |
| | | proxy?.$modal?.msgSuccess?.(result === "approved" ? "å·²éè¿" : "已驳å"); |
| | | approveDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function handleExport() { |
| | | const data = filteredList.value; |
| | | const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }); |
| | | const url = URL.createObjectURL(blob); |
| | | const a = document.createElement("a"); |
| | | a.href = url; |
| | | a.download = `å·®æ
æ¥é导åº_${dayjs().format("YYYYMMDDHHmmss")}.json`; |
| | | a.click(); |
| | | URL.revokeObjectURL(url); |
| | | proxy?.$modal?.msgSuccess?.(`å·²å¯¼åº ${data.length} æ¡`); |
| | | } |
| | | |
| | | function handleImportClick() { |
| | | importInputRef.value?.click?.(); |
| | | } |
| | | |
| | | function onImportFile(e) { |
| | | const file = e.target.files?.[0]; |
| | | e.target.value = ""; |
| | | if (!file) return; |
| | | const reader = new FileReader(); |
| | | reader.onload = () => { |
| | | try { |
| | | const parsed = JSON.parse(String(reader.result || "")); |
| | | const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data; |
| | | if (!Array.isArray(arr) || !arr.length) { |
| | | proxy?.$modal?.msgWarning?.("导å
¥æ ¼å¼é¡»ä¸ºå·®æ
æ¥é JSON æ°ç»"); |
| | | return; |
| | | } |
| | | arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i))); |
| | | proxy?.$modal?.msgSuccess?.(`æå导å
¥ ${arr.length} æ¡`); |
| | | handleQuery(); |
| | | } catch { |
| | | proxy?.$modal?.msgError?.("è§£æå¤±è´¥"); |
| | | } |
| | | }; |
| | | reader.readAsText(file, "utf-8"); |
| | | } |
| | | |
| | | onMounted(() => loadUserPool()); |
| | | |
| | | return { |
| | | Search, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | expenseSubjectLabel, |
| | | searchForm, |
| | | tableLoading, |
| | | page, |
| | | tableData, |
| | | tableColumn, |
| | | importInputRef, |
| | | formRef, |
| | | form, |
| | | formDialog, |
| | | formRules, |
| | | detailDialog, |
| | | detailRow, |
| | | approveDialog, |
| | | approveOpinion, |
| | | applicantFormSearchLoading, |
| | | applicantFormOptions, |
| | | flowUserOptions, |
| | | travelDaysDisplay, |
| | | travelTierLabel, |
| | | suggestedLivingSubsidy, |
| | | suggestedTransportSubsidy, |
| | | suggestedHotelLimit, |
| | | detailTotalAmount, |
| | | overBudgetWarnings, |
| | | budgetHint, |
| | | handleQuery, |
| | | resetSearch, |
| | | pagination, |
| | | remoteSearchApplicantForm, |
| | | userSelectLabel, |
| | | onApplicantChange, |
| | | recalcTravelStandards, |
| | | onTravelRangeChange, |
| | | onDetailAmountChange, |
| | | onApprovalFlowChange, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | syncApplyAmountFromDetails, |
| | | openFormDialog, |
| | | onFormClosed, |
| | | submitForm, |
| | | openDetail, |
| | | openApprove, |
| | | approvalActionLabel, |
| | | submitApprove, |
| | | handleExport, |
| | | handleImportClick, |
| | | onImportFile, |
| | | }; |
| | | } |