| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | }; |
| | | } |