合并OA流程页面文件夹 dev-new_pro_OA -> dev_NEW_pro
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å®¡æ¹å表--> |
| | | <template> |
| | | <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 { 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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å®¡æ¹æ¨¡æ¿ï¼ç³»ç»å¸¸ç¨ + èªå®ä¹å¤èç¹æµç¨ï¼--> |
| | | <template> |
| | | <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 { 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, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼è¯·åç³è¯·--> |
| | | <template> |
| | | <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-select v-model="searchForm.leaveType" placeholder="å
¨é¨" clearable style="width: 180px"> |
| | | <el-option v-for="opt in LEAVE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <el-button type="primary" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div> |
| | | <el-button type="primary" @click="openFormDialog('add')">æ°å¢è¯·åç³è¯·</el-button> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="960px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="leave-apply-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="leave-apply-form"> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <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="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="请åç±»å" prop="leaveType"> |
| | | <el-select v-model="form.leaveType" placeholder="è¯·éæ©è¯·åç±»å" clearable filterable style="width: 100%"> |
| | | <el-option v-for="opt in LEAVE_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="åæä½é¢" prop="leaveBalanceDays"> |
| | | <el-input-number |
| | | v-model="form.leaveBalanceDays" |
| | | :min="0" |
| | | :max="999" |
| | | :precision="2" |
| | | :step="0.5" |
| | | controls-position="right" |
| | | placeholder="天" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="请åå¼å§æ¶é´" prop="leaveStartTime"> |
| | | <el-date-picker |
| | | v-model="form.leaveStartTime" |
| | | type="datetime" |
| | | placeholder="è¯·éæ©å¼å§æ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | @change="onLeaveRangeChange" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="请åç»ææ¶é´" prop="leaveEndTime"> |
| | | <el-date-picker |
| | | v-model="form.leaveEndTime" |
| | | type="datetime" |
| | | placeholder="è¯·éæ©ç»ææ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | @change="onLeaveRangeChange" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è¯·åæ¶é¿"> |
| | | <el-input :model-value="leaveDurationDisplay" readonly placeholder="æ ¹æ®èµ·æ¢æ¶é´èªå¨è®¡ç®"> |
| | | <template #append>天</template> |
| | | </el-input> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å®¡æ¹æ¹å¼" prop="approvalMode"> |
| | | <el-radio-group v-model="form.approvalMode"> |
| | | <el-radio value="parallel">ä¸ç¾</el-radio> |
| | | <el-radio value="or_sign">æç¾</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="审æ¹äºº" prop="approverIds"> |
| | | <el-tree-select |
| | | v-model="form.approverIds" |
| | | :data="approverTreeData" |
| | | multiple |
| | | collapse-tags |
| | | collapse-tags-tooltip |
| | | :max-collapse-tags="2" |
| | | :render-after-expand="false" |
| | | placeholder="è¯·éæ©å®¡æ¹äººï¼å¯å¤éï¼" |
| | | style="width: 100%" |
| | | :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }" |
| | | check-strictly |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="请åäºç±" prop="leaveReason"> |
| | | <el-input |
| | | v-model="form.leaveReason" |
| | | type="textarea" |
| | | :rows="4" |
| | | placeholder="请填å请åäºç±" |
| | | maxlength="2000" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="éä»¶"> |
| | | <div class="upload-block"> |
| | | <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="ç¹å»éæ©æä»¶" /> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="请åç³è¯·è¯¦æ
" width="720px" append-to-body> |
| | | <el-descriptions :column="1" border> |
| | | <el-descriptions-item label="ç³è¯·äººç¼å·">{{ detailRow.applicantNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·äºº">{{ detailRow.applicantName }}</el-descriptions-item> |
| | | <el-descriptions-item label="请åç±»å">{{ leaveTypeLabel(detailRow.leaveType) }}</el-descriptions-item> |
| | | <el-descriptions-item label="åæä½é¢">{{ formatBalance(detailRow.leaveBalanceDays) }}</el-descriptions-item> |
| | | <el-descriptions-item label="请åå¼å§æ¶é´">{{ detailRow.leaveStartTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="请åç»ææ¶é´">{{ detailRow.leaveEndTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="è¯·åæ¶é¿">{{ formatDuration(detailRow.leaveDurationDays) }}</el-descriptions-item> |
| | | <el-descriptions-item label="请åäºç±">{{ detailRow.leaveReason }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç»æ">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ¹æ¹å¼">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹äºº">{{ detailRow.approverNames || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´">{{ detailRow.createTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="éä»¶"> |
| | | <template v-if="detailRow.attachmentList?.length"> |
| | | <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info"> |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <span v-else>æ </span> |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- éä»¶å表 --> |
| | | <el-dialog v-model="filesDialog.visible" title="éä»¶" width="520px" append-to-body> |
| | | <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border> |
| | | <el-table-column type="index" label="åºå·" width="60" align="center" /> |
| | | <el-table-column prop="name" label="æä»¶å" min-width="200" show-overflow-tooltip /> |
| | | <el-table-column label="æä½" width="100" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button link type="primary" @click="mockDownload(row)">ä¸è½½</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <el-empty v-else description="ææ éä»¶" /> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="filesDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | |
| | | /** 请åç±»åï¼value ä¸å端对é½å ä½ï¼ */ |
| | | const LEAVE_TYPE_OPTIONS = [ |
| | | { label: "å¹´å", value: "annual" }, |
| | | { label: "ç
å", value: "sick" }, |
| | | { label: "äºå", value: "personal" }, |
| | | { label: "å©å", value: "marriage" }, |
| | | { label: "产å", value: "maternity" }, |
| | | { label: "åºä¹³å", value: "nursing" }, |
| | | { label: "æ
°åå", value: "condolence" }, |
| | | { label: "è°ä¼", value: "compensatory" }, |
| | | ]; |
| | | |
| | | function leaveTypeLabel(v) { |
| | | const hit = LEAVE_TYPE_OPTIONS.find((x) => x.value === v); |
| | | return hit?.label || "â"; |
| | | } |
| | | |
| | | /** ä¸å端约å®å段ï¼å ä½ï¼ */ |
| | | const createEmptyForm = () => ({ |
| | | id: undefined, |
| | | applicantId: "", |
| | | applicantNo: "", |
| | | applicantName: "", |
| | | leaveType: "", |
| | | leaveBalanceDays: undefined, |
| | | leaveStartTime: "", |
| | | leaveEndTime: "", |
| | | leaveReason: "", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | attachmentList: [], |
| | | }); |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload && Array.isArray(payload.data)) return payload.data; |
| | | if (payload && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function filterDisabledDept(deptList) { |
| | | if (!Array.isArray(deptList)) return []; |
| | | return deptList.filter((dept) => { |
| | | if (dept.disabled) return false; |
| | | if (dept.children?.length) { |
| | | dept.children = filterDisabledDept(dept.children); |
| | | } |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | function getUserDeptId(u) { |
| | | return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id; |
| | | } |
| | | |
| | | function getDeptNodeKey(node) { |
| | | const k = node?.id ?? node?.value ?? node?.deptId; |
| | | if (k == null || k === "") return null; |
| | | return k; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | function userToTreeLeaf(u) { |
| | | return { |
| | | id: String(u.userId ?? u.id), |
| | | label: u.nickName || u.userName || `ç¨æ·${u.userId ?? u.id}`, |
| | | }; |
| | | } |
| | | |
| | | function buildUsersByDeptId(users) { |
| | | const map = new Map(); |
| | | const unassigned = []; |
| | | for (const u of users) { |
| | | if (!isActiveUser(u)) continue; |
| | | const did = getUserDeptId(u); |
| | | if (did == null || did === "" || did === 0 || did === "0") { |
| | | unassigned.push(u); |
| | | continue; |
| | | } |
| | | const k = String(did); |
| | | if (!map.has(k)) map.set(k, []); |
| | | map.get(k).push(u); |
| | | } |
| | | return { map, unassigned }; |
| | | } |
| | | |
| | | function collectUserLabels(nodes, map) { |
| | | (nodes || []).forEach((n) => { |
| | | if (n.children?.length) { |
| | | collectUserLabels(n.children, map); |
| | | } else if (n.id != null && !String(n.id).startsWith("dept_")) { |
| | | map[String(n.id)] = n.label; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | function mergeDeptTreeWithUsers(nodes, usersByDept) { |
| | | if (!Array.isArray(nodes)) return []; |
| | | const out = []; |
| | | for (const node of nodes) { |
| | | const deptIdRaw = getDeptNodeKey(node); |
| | | if (deptIdRaw == null) continue; |
| | | const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept); |
| | | const usersHere = usersByDept.get(String(deptIdRaw)) || []; |
| | | const userChildren = usersHere.map(userToTreeLeaf); |
| | | const children = [...sub, ...userChildren]; |
| | | if (!children.length) continue; |
| | | out.push({ |
| | | id: `dept_${deptIdRaw}`, |
| | | label: node.label ?? node.deptName ?? "é¨é¨", |
| | | disabled: true, |
| | | children, |
| | | }); |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | function buildFlatApproverTree(users) { |
| | | const list = users.filter(isActiveUser).map(userToTreeLeaf); |
| | | if (!list.length) return []; |
| | | return [ |
| | | { |
| | | id: "dept_all_users", |
| | | label: "ç³»ç»ç¨æ·", |
| | | disabled: true, |
| | | children: list, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | const approverTreeData = ref([]); |
| | | const approverLabelMap = ref({}); |
| | | |
| | | async function loadApproverTree() { |
| | | try { |
| | | const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]); |
| | | let rawTree = unwrapArray(deptRes); |
| | | rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : []; |
| | | let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree))); |
| | | if (!deptTree.length && rawTree.length) { |
| | | deptTree = JSON.parse(JSON.stringify(rawTree)); |
| | | } |
| | | const users = unwrapArray(userRes); |
| | | const { map: usersByDept, unassigned } = buildUsersByDeptId(users); |
| | | let merged = mergeDeptTreeWithUsers(deptTree, usersByDept); |
| | | if (unassigned.length) { |
| | | merged.push({ |
| | | id: "dept_unassigned", |
| | | label: "æªåé
é¨é¨", |
| | | disabled: true, |
| | | children: unassigned.map(userToTreeLeaf), |
| | | }); |
| | | } |
| | | if (!merged.length && users.length) { |
| | | merged = buildFlatApproverTree(users); |
| | | } |
| | | approverTreeData.value = merged; |
| | | const map = {}; |
| | | collectUserLabels(merged, map); |
| | | approverLabelMap.value = map; |
| | | } catch { |
| | | approverTreeData.value = []; |
| | | approverLabelMap.value = {}; |
| | | proxy?.$modal?.msgWarning?.("审æ¹äººæ°æ®å 载失败ï¼è¯·æ£æ¥ç½ç»æç¨åéè¯"); |
| | | } |
| | | } |
| | | |
| | | function resolveApproverNames(ids) { |
| | | if (!ids?.length) return ""; |
| | | const map = approverLabelMap.value; |
| | | return ids.map((id) => map[String(id)] || id).join("ã"); |
| | | } |
| | | |
| | | function approvalModeLabel(mode) { |
| | | if (mode === "or_sign") return "æç¾"; |
| | | return "ä¸ç¾"; |
| | | } |
| | | |
| | | function approvalResultLabel(v) { |
| | | if (v === "approved") return "å·²éè¿"; |
| | | if (v === "rejected") return "已驳å"; |
| | | if (v === "cancelled") return "å·²æ¤é"; |
| | | return "å¾
审æ¹"; |
| | | } |
| | | |
| | | /** æèµ·æ¢æ¶é´è®¡ç®è¯·å天æ°ï¼å«æ¶åç§ï¼ç»æä¿ç两ä½å°æ°ï¼ */ |
| | | function computeLeaveDays(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 = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000); |
| | | return Math.round(days * 100) / 100; |
| | | } |
| | | |
| | | function formatDuration(v) { |
| | | if (v == null || v === "") return "â"; |
| | | return `${v} 天`; |
| | | } |
| | | |
| | | function formatBalance(v) { |
| | | if (v == null || v === "") return "â"; |
| | | return `${v} 天`; |
| | | } |
| | | |
| | | /** ç³»ç»ç¨æ·ç¼å */ |
| | | const allUsersCache = ref([]); |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | allUsersCache.value = unwrapArray(res); |
| | | } 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) { |
| | | if (id == null || id === "") return undefined; |
| | | return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function applicantNoFromUser(u) { |
| | | if (!u) return ""; |
| | | return ( |
| | | u.userName ?? |
| | | u.userCode ?? |
| | | u.jobNumber ?? |
| | | u.workNo ?? |
| | | (u.userId != null ? String(u.userId) : "") |
| | | ); |
| | | } |
| | | |
| | | /** æ¬å°æ¨¡æï¼æ ¹æ®ç¨æ·çæç¨³å®ãåæä½é¢ãå ä½ */ |
| | | function mockLeaveBalance(u) { |
| | | if (!u) return undefined; |
| | | const idStr = String(u.userId ?? u.id ?? "0"); |
| | | let s = 0; |
| | | for (let i = 0; i < idStr.length; i++) s += idStr.charCodeAt(i); |
| | | return Math.round(((s % 130) / 10 + 5) * 100) / 100; |
| | | } |
| | | |
| | | function filterUsersByQuery(query) { |
| | | const list = allUsersCache.value.filter((u) => isActiveUser(u)); |
| | | const q = (query || "").trim().toLowerCase(); |
| | | if (!q) return [...list]; |
| | | return list.filter((u) => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const uname = (u.userName || "").toLowerCase(); |
| | | const phone = (u.phonenumber || u.phone || "").toString(); |
| | | return nick.includes(q) || uname.includes(q) || phone.includes(q); |
| | | }); |
| | | } |
| | | |
| | | const applicantFormSearchLoading = ref(false); |
| | | const applicantFormOptions = ref([]); |
| | | |
| | | 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.applicantName = u.nickName || u.userName || ""; |
| | | form.applicantNo = applicantNoFromUser(u); |
| | | form.leaveBalanceDays = mockLeaveBalance(u); |
| | | } else { |
| | | form.applicantName = ""; |
| | | form.applicantNo = ""; |
| | | form.leaveBalanceDays = undefined; |
| | | } |
| | | } |
| | | |
| | | /** æ¬å°æ¨¡æåè¡¨æ°æ® */ |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | applicantId: "mock_1", |
| | | applicantNo: "zhangsan", |
| | | applicantName: "å¼ ä¸", |
| | | leaveType: "annual", |
| | | leaveBalanceDays: 12, |
| | | leaveStartTime: "2026-05-10 09:00:00", |
| | | leaveEndTime: "2026-05-12 18:00:00", |
| | | leaveDurationDays: 2.38, |
| | | leaveReason: "å¹´ä¼åè¿ä¹¡æ¢äº²ã", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | approvalResult: "pending", |
| | | attachmentList: [{ name: "车票订å.pdf" }], |
| | | createTime: "2026-05-09 10:20:00", |
| | | }, |
| | | { |
| | | id: "2", |
| | | applicantId: "mock_2", |
| | | applicantNo: "lisi", |
| | | applicantName: "æå", |
| | | leaveType: "sick", |
| | | leaveBalanceDays: 0, |
| | | leaveStartTime: "2026-05-14 08:30:00", |
| | | leaveEndTime: "2026-05-14 12:00:00", |
| | | leaveDurationDays: 0.15, |
| | | leaveReason: "ä¸åé¨è¯å¤æ¥ã", |
| | | approvalMode: "or_sign", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | approvalResult: "approved", |
| | | attachmentList: [], |
| | | createTime: "2026-05-13 16:00:00", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantKeyword: "", |
| | | leaveType: "", |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | total: 0, |
| | | }); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | 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); |
| | | }); |
| | | } |
| | | if (searchForm.leaveType) { |
| | | list = list.filter((r) => r.leaveType === searchForm.leaveType); |
| | | } |
| | | 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 list = filteredList.value; |
| | | const start = (page.current - 1) * page.size; |
| | | return list.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç³è¯·äººç¼å·", prop: "applicantNo", width: 120 }, |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 100 }, |
| | | { |
| | | label: "请åç±»å", |
| | | prop: "leaveType", |
| | | width: 100, |
| | | formatData: (v) => leaveTypeLabel(v), |
| | | }, |
| | | { |
| | | label: "è¯·åæ¶é¿", |
| | | prop: "leaveDurationDays", |
| | | width: 120, |
| | | formatData: (v) => (v == null || v === "" ? "â" : `${v} 天`), |
| | | }, |
| | | { label: "请åäºç±", prop: "leaveReason", minWidth: 180 }, |
| | | { label: "å建æ¶é´", prop: "createTime", width: 170 }, |
| | | { |
| | | label: "审æ¹ç»æ", |
| | | prop: "approvalResult", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => approvalResultLabel(v), |
| | | formatType: (v) => { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | if (v === "cancelled") return "info"; |
| | | return "warning"; |
| | | }, |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 220, |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { |
| | | name: "æ¥ç详æ
", |
| | | type: "text", |
| | | clickFun: (row) => openDetail(row), |
| | | }, |
| | | { |
| | | name: "éä»¶", |
| | | type: "text", |
| | | clickFun: (row) => openFiles(row), |
| | | }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formDialog = reactive({ |
| | | visible: false, |
| | | title: "", |
| | | mode: "add", |
| | | }); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | |
| | | const leaveDurationDisplay = computed(() => { |
| | | const d = computeLeaveDays(form.leaveStartTime, form.leaveEndTime); |
| | | return d == null ? "" : String(d); |
| | | }); |
| | | |
| | | function onLeaveRangeChange() { |
| | | nextTick(() => { |
| | | formRef.value?.validateField?.("leaveEndTime"); |
| | | }); |
| | | } |
| | | |
| | | const formRules = { |
| | | applicantId: [{ required: true, message: "è¯·éæ©ç³è¯·äºº", trigger: "change" }], |
| | | leaveType: [{ required: true, message: "è¯·éæ©è¯·åç±»å", trigger: "change" }], |
| | | leaveBalanceDays: [ |
| | | { |
| | | required: true, |
| | | message: "请填ååæä½é¢", |
| | | trigger: "blur", |
| | | }, |
| | | ], |
| | | leaveStartTime: [{ required: true, message: "è¯·éæ©è¯·åå¼å§æ¶é´", trigger: "change" }], |
| | | leaveEndTime: [ |
| | | { required: true, message: "è¯·éæ©è¯·åç»ææ¶é´", trigger: "change" }, |
| | | { |
| | | validator: (_rule, val, callback) => { |
| | | if (!form.leaveStartTime || !val) { |
| | | callback(); |
| | | return; |
| | | } |
| | | const d = computeLeaveDays(form.leaveStartTime, val); |
| | | if (d == null) { |
| | | callback(new Error("ç»ææ¶é´é¡»æäºå¼å§æ¶é´")); |
| | | } else { |
| | | callback(); |
| | | } |
| | | }, |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | leaveReason: [{ required: true, message: "请填å请åäºç±", trigger: "blur" }], |
| | | approvalMode: [{ required: true, message: "è¯·éæ©å®¡æ¹æ¹å¼", trigger: "change" }], |
| | | approverIds: [ |
| | | { |
| | | type: "array", |
| | | required: true, |
| | | message: "è¯·éæ©å®¡æ¹äºº", |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const filesDialog = reactive({ visible: false, row: null }); |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.applicantKeyword = ""; |
| | | searchForm.leaveType = ""; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openFiles(row) { |
| | | filesDialog.row = row; |
| | | filesDialog.visible = true; |
| | | } |
| | | |
| | | function mockDownload(row) { |
| | | const url = row.url || row.downloadURL || row.previewURL || row.previewUrl; |
| | | if (url) { |
| | | window.open(url, "_blank"); |
| | | return; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.(`已模æä¸è½½ï¼${row.name}`); |
| | | } |
| | | |
| | | async function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.title = mode === "add" ? "æ°å¢è¯·åç³è¯·" : "ç¼è¾è¯·åç³è¯·"; |
| | | await loadApproverTree(); |
| | | if (!allUsersCache.value.length) { |
| | | await loadUserPool(); |
| | | } |
| | | Object.assign(form, createEmptyForm()); |
| | | if (mode === "edit" && row) { |
| | | Object.assign(form, { |
| | | id: row.id, |
| | | applicantId: row.applicantId, |
| | | applicantNo: row.applicantNo, |
| | | applicantName: row.applicantName, |
| | | leaveType: row.leaveType, |
| | | leaveBalanceDays: row.leaveBalanceDays, |
| | | leaveStartTime: row.leaveStartTime, |
| | | leaveEndTime: row.leaveEndTime, |
| | | leaveReason: row.leaveReason, |
| | | approvalMode: row.approvalMode === "countersign" ? "or_sign" : row.approvalMode || "parallel", |
| | | approverIds: (row.approverIds || []).map((id) => String(id)), |
| | | approverNames: row.approverNames, |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])), |
| | | }); |
| | | const u = userById(row.applicantId); |
| | | if (u) { |
| | | applicantFormOptions.value = [u]; |
| | | } else if (row.applicantId) { |
| | | applicantFormOptions.value = [ |
| | | { |
| | | userId: row.applicantId, |
| | | nickName: row.applicantName, |
| | | userName: row.applicantNo, |
| | | }, |
| | | ]; |
| | | } |
| | | } else { |
| | | remoteSearchApplicantForm(""); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => formRef.value?.clearValidate?.()); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | const days = computeLeaveDays(form.leaveStartTime, form.leaveEndTime); |
| | | if (days == null) { |
| | | proxy?.$modal?.msgWarning?.("è¯·æ£æ¥è¯·åèµ·æ¢æ¶é´ï¼ç»ææ¶é´é¡»æäºå¼å§æ¶é´"); |
| | | return; |
| | | } |
| | | form.approverNames = resolveApproverNames(form.approverIds); |
| | | const payload = { |
| | | applicantId: form.applicantId, |
| | | applicantNo: form.applicantNo, |
| | | applicantName: form.applicantName, |
| | | leaveType: form.leaveType, |
| | | leaveBalanceDays: form.leaveBalanceDays, |
| | | leaveStartTime: form.leaveStartTime, |
| | | leaveEndTime: form.leaveEndTime, |
| | | leaveDurationDays: days, |
| | | leaveReason: form.leaveReason, |
| | | approvalMode: form.approvalMode, |
| | | approverIds: [...form.approverIds], |
| | | approverNames: form.approverNames, |
| | | attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])), |
| | | }; |
| | | if (formDialog.mode === "add") { |
| | | const id = `local_${Date.now()}`; |
| | | allRows.value.unshift({ |
| | | id, |
| | | ...payload, |
| | | approvalResult: "pending", |
| | | 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, |
| | | id: form.id, |
| | | ...payload, |
| | | approvalResult: prev.approvalResult ?? "pending", |
| | | createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadApproverTree(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_title { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-regular); |
| | | } |
| | | .upload-block { |
| | | width: 100%; |
| | | } |
| | | .mr6 { |
| | | margin-right: 6px; |
| | | } |
| | | .mb6 { |
| | | margin-bottom: 6px; |
| | | } |
| | | .leave-apply-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | .leave-apply-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .leave-apply-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- å çç³è¯·æ¨¡åå
ï¼å¯å¢å 审æ¹èç¹ï¼æ¯èç¹å¿
é 1 人 --> |
| | | <template> |
| | | <div class="afe"> |
| | | <div v-if="innerList.length" class="afe-flow"> |
| | | <div v-for="(item, index) in innerList" :key="item._uid" class="afe-flow-item"> |
| | | <div class="afe-card" :class="{ 'afe-card--empty': !item.approverId }"> |
| | | <div class="afe-badge">{{ index + 1 }}</div> |
| | | <div class="afe-avatar-wrap"> |
| | | <div |
| | | class="afe-avatar" |
| | | :class="{ 'afe-avatar--on': item.approverId }" |
| | | :style="item.approverId ? { backgroundColor: avatarColor(item.approverName) } : {}" |
| | | > |
| | | <span v-if="item.approverId">{{ (item.approverName || '?').charAt(0) }}</span> |
| | | <el-icon v-else :size="22"><User /></el-icon> |
| | | </div> |
| | | <div class="afe-level">{{ levelText(index) }}</div> |
| | | </div> |
| | | <div class="afe-select"> |
| | | <el-select |
| | | v-model="item.approverId" |
| | | placeholder="è¯·éæ©å®¡æ¹äºº" |
| | | filterable |
| | | clearable |
| | | style="width: 100%" |
| | | @change="(v) => onPick(v, 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 class="afe-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="afe-conn"> |
| | | <div class="afe-conn-line"></div> |
| | | <el-icon class="afe-conn-icon"><ArrowRight /></el-icon> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="afe-add-wrap"> |
| | | <div class="afe-conn" v-if="innerList.length"> |
| | | <div class="afe-conn-line"></div> |
| | | <el-icon class="afe-conn-icon"><ArrowRight /></el-icon> |
| | | </div> |
| | | <div class="afe-add-card" @click="addNode"> |
| | | <div class="afe-add-icon"><el-icon :size="26"><Plus /></el-icon></div> |
| | | <span>æ°å¢èç¹</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-else class="afe-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"; |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { type: Array, default: () => [] }, |
| | | /** ä¸ç¶é¡µ userList ç»æä¸è´ï¼userId / idãnickNameãuserName */ |
| | | userOptions: { type: Array, default: () => [] }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:modelValue"]); |
| | | |
| | | const innerList = ref([]); |
| | | |
| | | const palette = ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#9B59B6", "#1ABC9C"]; |
| | | |
| | | function avatarColor(name) { |
| | | if (!name) return "#c0c4cc"; |
| | | let h = 0; |
| | | for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h); |
| | | return palette[Math.abs(h) % palette.length]; |
| | | } |
| | | |
| | | 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) { |
| | | if (!Array.isArray(rows)) return []; |
| | | return rows.map((r, i) => ({ |
| | | _uid: newUid(), |
| | | approverId: r.approverId ?? r.approver_id ?? null, |
| | | approverName: r.approverName ?? r.approver_name ?? "", |
| | | sortOrder: r.sortOrder ?? r.nodeOrder ?? i + 1, |
| | | nodeOrder: r.nodeOrder ?? r.sortOrder ?? i + 1, |
| | | roleName: r.roleName ?? "", |
| | | roleCode: r.roleCode ?? "", |
| | | })); |
| | | } |
| | | |
| | | function publicShape(rows) { |
| | | const arr = Array.isArray(rows) ? rows : []; |
| | | return arr.map((r, i) => ({ |
| | | approverId: r.approverId ?? null, |
| | | approverName: r.approverName ?? "", |
| | | roleName: r.roleName ?? "", |
| | | roleCode: r.roleCode ?? "", |
| | | sortOrder: i + 1, |
| | | })); |
| | | } |
| | | |
| | | function emitOut() { |
| | | const out = innerList.value.map((r, i) => ({ |
| | | approverId: r.approverId ?? null, |
| | | approverName: r.approverName ?? "", |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | roleName: r.roleName ?? "", |
| | | roleCode: r.roleCode ?? "", |
| | | })); |
| | | emit("update:modelValue", out); |
| | | } |
| | | |
| | | 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 onPick(userId, row) { |
| | | if (!userId) { |
| | | row.approverName = ""; |
| | | emitOut(); |
| | | return; |
| | | } |
| | | const u = findUser(userId); |
| | | row.approverName = u ? u.nickName || u.userName || "" : ""; |
| | | emitOut(); |
| | | } |
| | | |
| | | function addNode() { |
| | | innerList.value.push({ |
| | | _uid: newUid(), |
| | | approverId: null, |
| | | approverName: "", |
| | | roleName: "", |
| | | roleCode: "", |
| | | }); |
| | | 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> |
| | | .afe { |
| | | width: 100%; |
| | | } |
| | | .afe-flow { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | flex-wrap: nowrap; |
| | | overflow-x: auto; |
| | | padding: 6px 0 10px; |
| | | gap: 0; |
| | | } |
| | | .afe-flow-item { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | .afe-card { |
| | | width: 200px; |
| | | flex-shrink: 0; |
| | | border: 2px solid var(--el-border-color); |
| | | border-radius: 12px; |
| | | padding: 14px 12px 12px; |
| | | position: relative; |
| | | background: var(--el-bg-color); |
| | | } |
| | | .afe-card--empty { |
| | | border-style: dashed; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .afe-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; |
| | | } |
| | | .afe-avatar-wrap { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | margin: 6px 0 10px; |
| | | } |
| | | .afe-avatar { |
| | | width: 48px; |
| | | height: 48px; |
| | | border-radius: 50%; |
| | | background: var(--el-fill-color); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: var(--el-text-color-placeholder); |
| | | margin-bottom: 6px; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | } |
| | | .afe-avatar--on { |
| | | color: #fff; |
| | | } |
| | | .afe-level { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | } |
| | | .afe-select { |
| | | margin-bottom: 10px; |
| | | } |
| | | .afe-actions { |
| | | display: flex; |
| | | justify-content: center; |
| | | gap: 8px; |
| | | padding-top: 10px; |
| | | border-top: 1px solid var(--el-border-color-lighter); |
| | | } |
| | | .afe-conn { |
| | | display: flex; |
| | | align-items: center; |
| | | width: 40px; |
| | | flex-shrink: 0; |
| | | align-self: center; |
| | | } |
| | | .afe-conn-line { |
| | | flex: 1; |
| | | height: 2px; |
| | | background: var(--el-border-color); |
| | | } |
| | | .afe-conn-icon { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-placeholder); |
| | | margin-left: -2px; |
| | | } |
| | | .afe-add-wrap { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | .afe-add-card { |
| | | width: 120px; |
| | | min-height: 168px; |
| | | 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; |
| | | } |
| | | .afe-add-card:hover { |
| | | border-color: var(--el-color-primary); |
| | | background: var(--el-color-primary-light-9); |
| | | color: var(--el-color-primary); |
| | | } |
| | | .afe-add-icon { |
| | | width: 44px; |
| | | height: 44px; |
| | | border-radius: 50%; |
| | | background: var(--el-color-primary); |
| | | color: #fff; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | .afe-empty { |
| | | text-align: center; |
| | | padding: 28px 16px; |
| | | border: 1px dashed var(--el-border-color); |
| | | border-radius: 12px; |
| | | background: var(--el-fill-color-lighter); |
| | | } |
| | | .afe-empty p { |
| | | margin: 10px 0 14px; |
| | | color: var(--el-text-color-secondary); |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å çç³è¯·--> |
| | | <template> |
| | | <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-select v-model="searchForm.overtimeType" placeholder="å
¨é¨" clearable style="width: 180px"> |
| | | <el-option v-for="opt in OVERTIME_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <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" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="1040px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="overtime-apply-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="overtime-apply-form"> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <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="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å çç±»å" prop="overtimeType"> |
| | | <el-select v-model="form.overtimeType" placeholder="è¯·éæ©å çç±»å" clearable filterable style="width: 100%"> |
| | | <el-option v-for="opt in OVERTIME_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="å çæ¥æ" prop="overtimeDate"> |
| | | <el-date-picker |
| | | v-model="form.overtimeDate" |
| | | type="date" |
| | | placeholder="è¯·éæ©å çæ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å çå¼å§æ¥æ" prop="overtimeStartTime"> |
| | | <el-date-picker |
| | | v-model="form.overtimeStartTime" |
| | | type="datetime" |
| | | placeholder="è¯·éæ©å¼å§æ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | @change="onOvertimeRangeChange" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å çç»ææ¥æ" prop="overtimeEndTime"> |
| | | <el-date-picker |
| | | v-model="form.overtimeEndTime" |
| | | type="datetime" |
| | | placeholder="è¯·éæ©ç»ææ¶é´" |
| | | format="YYYY-MM-DD HH:mm:ss" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | style="width: 100%" |
| | | @change="onOvertimeRangeChange" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å çæ¶é¿"> |
| | | <el-input :model-value="overtimeHoursDisplay" readonly placeholder="æ ¹æ®èµ·æ¢æ¶é´èªå¨è®¡ç®"> |
| | | <template #append>å°æ¶</template> |
| | | </el-input> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="å®¡æ¹æµç¨" prop="approvalFlowNodes"> |
| | | <ApprovalFlowEditor |
| | | v-model="form.approvalFlowNodes" |
| | | :user-options="flowUserOptions" |
| | | @update:model-value="onApprovalFlowChange" |
| | | /> |
| | | <p class="flow-tip">è³å°ä¿çä¸ä¸ªèç¹ï¼æ¯ä¸ªèç¹éæ©ä¸å审æ¹äººï¼å¯æ°å¢ãå é¤æè°æ´é¡ºåºã</p> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="å çäºç±" prop="overtimeReason"> |
| | | <el-input |
| | | v-model="form.overtimeReason" |
| | | type="textarea" |
| | | :rows="4" |
| | | placeholder="请填åå çäºç±" |
| | | maxlength="2000" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="éä»¶"> |
| | | <div class="upload-block"> |
| | | <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="ç¹å»éæ©æä»¶" /> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="å çç³è¯·è¯¦æ
" width="720px" append-to-body> |
| | | <el-descriptions :column="1" border> |
| | | <el-descriptions-item label="ç³è¯·äººç¼å·">{{ detailRow.applicantNo || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·äºº">{{ detailRow.applicantName }}</el-descriptions-item> |
| | | <el-descriptions-item label="å çç±»å">{{ overtimeTypeLabel(detailRow.overtimeType) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å çæ¥æ">{{ detailRow.overtimeDate || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å çå¼å§æ¥æ">{{ detailRow.overtimeStartTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å çç»ææ¥æ">{{ detailRow.overtimeEndTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="å çæ¶é¿">{{ formatHours(detailRow.overtimeHours) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å çäºç±">{{ detailRow.overtimeReason }}</el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ¹æµç¨"> |
| | | <template v-if="sortedApprovalNodes(detailRow).length"> |
| | | <div class="detail-flow-chain"> |
| | | <template v-for="(n, i) in sortedApprovalNodes(detailRow)" :key="i"> |
| | | <span class="detail-flow-step">{{ i + 1 }}. {{ approvalNodeLabel(n) }}</span> |
| | | <span v-if="i < sortedApprovalNodes(detailRow).length - 1" class="detail-flow-sep">â</span> |
| | | </template> |
| | | </div> |
| | | </template> |
| | | <span v-else>â</span> |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç»æ">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å建æ¶é´">{{ detailRow.createTime || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="éä»¶"> |
| | | <template v-if="detailRow.attachmentList?.length"> |
| | | <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info"> |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <span v-else>æ </span> |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- éä»¶å表 --> |
| | | <el-dialog v-model="filesDialog.visible" title="éä»¶" width="520px" append-to-body> |
| | | <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border> |
| | | <el-table-column type="index" label="åºå·" width="60" align="center" /> |
| | | <el-table-column prop="name" label="æä»¶å" min-width="200" show-overflow-tooltip /> |
| | | <el-table-column label="æä½" width="100" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button link type="primary" @click="mockDownload(row)">ä¸è½½</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <el-empty v-else description="ææ éä»¶" /> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="filesDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import dayjs from "dayjs"; |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import ApprovalFlowEditor from "./components/ApprovalFlowEditor.vue"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from "vue"; |
| | | |
| | | /** å çç±»åï¼value ä¸å端对é½å ä½ï¼ */ |
| | | const OVERTIME_TYPE_OPTIONS = [ |
| | | { label: "工使¥å ç", value: "weekday" }, |
| | | { label: "伿¯æ¥å ç", value: "weekend" }, |
| | | { label: "æ³å®è忥å ç", value: "holiday" }, |
| | | ]; |
| | | |
| | | /** æ¬å°æ¼ç¤ºï¼ä¸¤æ¡ç©ºèç¹ï¼æäº¤å须为æ¯èç¹éæ©å®¡æ¹äºº */ |
| | | function demoApprovalFlowNodes() { |
| | | return [ |
| | | { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" }, |
| | | { approverId: null, approverName: "", sortOrder: 2, nodeOrder: 2, roleName: "", roleCode: "" }, |
| | | ]; |
| | | } |
| | | |
| | | function sortedApprovalNodes(row) { |
| | | const list = row?.approvalFlowNodes; |
| | | if (!Array.isArray(list) || !list.length) return []; |
| | | return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0)); |
| | | } |
| | | |
| | | function approvalNodeLabel(n) { |
| | | const name = (n.approverName || "").trim(); |
| | | if (name) return name; |
| | | return "æªéæ©å®¡æ¹äºº"; |
| | | } |
| | | |
| | | function overtimeTypeLabel(v) { |
| | | const hit = OVERTIME_TYPE_OPTIONS.find((x) => x.value === v); |
| | | return hit?.label || "â"; |
| | | } |
| | | |
| | | const createEmptyForm = () => ({ |
| | | id: undefined, |
| | | applicantId: "", |
| | | applicantNo: "", |
| | | applicantName: "", |
| | | overtimeType: "", |
| | | overtimeDate: "", |
| | | overtimeStartTime: "", |
| | | overtimeEndTime: "", |
| | | overtimeReason: "", |
| | | attachmentList: [], |
| | | approvalFlowNodes: [ |
| | | { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" }, |
| | | ], |
| | | }); |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload && Array.isArray(payload.data)) return payload.data; |
| | | if (payload && 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 approvalResultLabel(v) { |
| | | if (v === "approved") return "å·²éè¿"; |
| | | if (v === "rejected") return "已驳å"; |
| | | if (v === "cancelled") return "å·²æ¤é"; |
| | | return "å¾
审æ¹"; |
| | | } |
| | | |
| | | /** æèµ·æ¢æ¶é´è®¡ç®å çæ¶é¿ï¼å°æ¶ï¼ä¿ç两ä½å°æ°ï¼ */ |
| | | function computeOvertimeHours(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 hours = t1.diff(t0, "millisecond") / (60 * 60 * 1000); |
| | | return Math.round(hours * 100) / 100; |
| | | } |
| | | |
| | | function formatHours(v) { |
| | | if (v == null || v === "") return "â"; |
| | | return `${v} å°æ¶`; |
| | | } |
| | | |
| | | const allUsersCache = ref([]); |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | allUsersCache.value = unwrapArray(res); |
| | | } 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) { |
| | | if (id == null || id === "") return undefined; |
| | | return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function applicantNoFromUser(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((u) => isActiveUser(u)); |
| | | const q = (query || "").trim().toLowerCase(); |
| | | if (!q) return [...list]; |
| | | return list.filter((u) => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const uname = (u.userName || "").toLowerCase(); |
| | | const phone = (u.phonenumber || u.phone || "").toString(); |
| | | return nick.includes(q) || uname.includes(q) || phone.includes(q); |
| | | }); |
| | | } |
| | | |
| | | const applicantFormSearchLoading = ref(false); |
| | | const applicantFormOptions = ref([]); |
| | | |
| | | 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.applicantName = u.nickName || u.userName || ""; |
| | | form.applicantNo = applicantNoFromUser(u); |
| | | } else { |
| | | form.applicantName = ""; |
| | | form.applicantNo = ""; |
| | | } |
| | | } |
| | | |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | applicantId: "mock_1", |
| | | applicantNo: "zhangsan", |
| | | applicantName: "å¼ ä¸", |
| | | overtimeType: "weekday", |
| | | overtimeDate: "2026-05-10", |
| | | overtimeStartTime: "2026-05-10 18:00:00", |
| | | overtimeEndTime: "2026-05-10 21:30:00", |
| | | overtimeHours: 3.5, |
| | | overtimeReason: "项ç®ä¸çº¿ä¿éã", |
| | | approvalFlowNodes: demoApprovalFlowNodes(), |
| | | approvalResult: "pending", |
| | | attachmentList: [{ name: "ä»»å¡å.pdf" }], |
| | | createTime: "2026-05-09 10:20:00", |
| | | }, |
| | | { |
| | | id: "2", |
| | | applicantId: "mock_2", |
| | | applicantNo: "lisi", |
| | | applicantName: "æå", |
| | | overtimeType: "weekend", |
| | | overtimeDate: "2026-05-11", |
| | | overtimeStartTime: "2026-05-11 09:00:00", |
| | | overtimeEndTime: "2026-05-11 12:15:00", |
| | | overtimeHours: 3.25, |
| | | overtimeReason: "客æ·ç°åºæ¯æã", |
| | | approvalFlowNodes: demoApprovalFlowNodes(), |
| | | approvalResult: "approved", |
| | | attachmentList: [], |
| | | createTime: "2026-05-10 16:00:00", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantKeyword: "", |
| | | overtimeType: "", |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | total: 0, |
| | | }); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | 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); |
| | | }); |
| | | } |
| | | if (searchForm.overtimeType) { |
| | | list = list.filter((r) => r.overtimeType === searchForm.overtimeType); |
| | | } |
| | | 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 list = filteredList.value; |
| | | const start = (page.current - 1) * page.size; |
| | | return list.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç³è¯·äººç¼å·", prop: "applicantNo", width: 120 }, |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 100 }, |
| | | { label: "å çæ¥æ", prop: "overtimeDate", width: 120 }, |
| | | { label: "å çå¼å§æ¥æ", prop: "overtimeStartTime", width: 170 }, |
| | | { label: "å çç»ææ¥æ", prop: "overtimeEndTime", width: 170 }, |
| | | { |
| | | label: "å çæ¶é¿", |
| | | prop: "overtimeHours", |
| | | width: 120, |
| | | formatData: (v) => (v == null || v === "" ? "â" : `${v} å°æ¶`), |
| | | }, |
| | | { |
| | | label: "审æ¹ç»æ", |
| | | prop: "approvalResult", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => approvalResultLabel(v), |
| | | formatType: (v) => { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | if (v === "cancelled") return "info"; |
| | | return "warning"; |
| | | }, |
| | | }, |
| | | { label: "å建æ¶é´", prop: "createTime", width: 170 }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 220, |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { |
| | | name: "æ¥ç详æ
", |
| | | type: "text", |
| | | clickFun: (row) => openDetail(row), |
| | | }, |
| | | { |
| | | name: "éä»¶", |
| | | type: "text", |
| | | clickFun: (row) => openFiles(row), |
| | | }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formDialog = reactive({ |
| | | visible: false, |
| | | title: "", |
| | | mode: "add", |
| | | }); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | |
| | | const flowUserOptions = computed(() => allUsersCache.value.filter((u) => isActiveUser(u))); |
| | | |
| | | const overtimeHoursDisplay = computed(() => { |
| | | const h = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime); |
| | | return h == null ? "" : String(h); |
| | | }); |
| | | |
| | | function onOvertimeRangeChange() { |
| | | nextTick(() => { |
| | | formRef.value?.validateField?.("overtimeEndTime"); |
| | | }); |
| | | } |
| | | |
| | | function onApprovalFlowChange() { |
| | | nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); |
| | | } |
| | | |
| | | const formRules = { |
| | | applicantId: [{ required: true, message: "è¯·éæ©ç³è¯·äºº", trigger: "change" }], |
| | | overtimeType: [{ required: true, message: "è¯·éæ©å çç±»å", trigger: "change" }], |
| | | overtimeDate: [{ required: true, message: "è¯·éæ©å çæ¥æ", trigger: "change" }], |
| | | overtimeStartTime: [{ required: true, message: "è¯·éæ©å çå¼å§æ¶é´", trigger: "change" }], |
| | | overtimeEndTime: [ |
| | | { required: true, message: "è¯·éæ©å çç»ææ¶é´", trigger: "change" }, |
| | | { |
| | | validator: (_rule, val, callback) => { |
| | | if (!form.overtimeStartTime || !val) { |
| | | callback(); |
| | | return; |
| | | } |
| | | const h = computeOvertimeHours(form.overtimeStartTime, val); |
| | | if (h == null) { |
| | | callback(new Error("ç»ææ¶é´é¡»æäºå¼å§æ¶é´")); |
| | | } else { |
| | | callback(); |
| | | } |
| | | }, |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | overtimeReason: [{ required: true, message: "请填åå çäºç±", trigger: "blur" }], |
| | | approvalFlowNodes: [ |
| | | { |
| | | validator: (_rule, _val, callback) => { |
| | | const nodes = form.approvalFlowNodes || []; |
| | | if (!nodes.length) { |
| | | callback(new Error("请è³å°ä¿çä¸ä¸ªå®¡æ¹èç¹")); |
| | | return; |
| | | } |
| | | if (nodes.some((n) => n.approverId == null || n.approverId === "")) { |
| | | callback(new Error("æ¯ä¸ªå®¡æ¹èç¹å¿
须鿩ä¸å审æ¹äºº")); |
| | | return; |
| | | } |
| | | const ids = nodes.map((n) => String(n.approverId)); |
| | | if (new Set(ids).size !== ids.length) { |
| | | callback(new Error("åä¸å®¡æ¹äººä¸è½éå¤åºç°å¨å¤ä¸ªèç¹")); |
| | | return; |
| | | } |
| | | callback(); |
| | | }, |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const filesDialog = reactive({ visible: false, row: null }); |
| | | |
| | | const importInputRef = ref(null); |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.applicantKeyword = ""; |
| | | searchForm.overtimeType = ""; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openFiles(row) { |
| | | filesDialog.row = row; |
| | | filesDialog.visible = true; |
| | | } |
| | | |
| | | function mockDownload(row) { |
| | | const url = row.url || row.downloadURL || row.previewURL || row.previewUrl; |
| | | if (url) { |
| | | window.open(url, "_blank"); |
| | | return; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.(`已模æä¸è½½ï¼${row.name}`); |
| | | } |
| | | |
| | | 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} æ¡ï¼å½åçéç»æï¼JSONï¼`); |
| | | } |
| | | |
| | | function handleImportClick() { |
| | | importInputRef.value?.click?.(); |
| | | } |
| | | |
| | | function normalizeImportedRow(raw, idx) { |
| | | const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`; |
| | | const hours = |
| | | raw.overtimeHours != null && raw.overtimeHours !== "" |
| | | ? Number(raw.overtimeHours) |
| | | : computeOvertimeHours(raw.overtimeStartTime, raw.overtimeEndTime); |
| | | return { |
| | | id, |
| | | applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`, |
| | | applicantNo: raw.applicantNo ?? "", |
| | | applicantName: raw.applicantName ?? "æªç¥", |
| | | overtimeType: raw.overtimeType || "weekday", |
| | | overtimeDate: raw.overtimeDate ?? "", |
| | | overtimeStartTime: raw.overtimeStartTime ?? "", |
| | | overtimeEndTime: raw.overtimeEndTime ?? "", |
| | | overtimeHours: hours == null || Number.isNaN(hours) ? 0 : Math.round(hours * 100) / 100, |
| | | overtimeReason: raw.overtimeReason ?? "", |
| | | approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length |
| | | ? raw.approvalFlowNodes.map((n) => ({ ...n })) |
| | | : [], |
| | | approvalResult: raw.approvalResult && ["pending", "approved", "rejected", "cancelled"].includes(raw.approvalResult) |
| | | ? raw.approvalResult |
| | | : "pending", |
| | | attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [], |
| | | createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }; |
| | | } |
| | | |
| | | function onImportFile(e) { |
| | | const input = e.target; |
| | | const file = input.files?.[0]; |
| | | input.value = ""; |
| | | if (!file) return; |
| | | const reader = new FileReader(); |
| | | reader.onload = () => { |
| | | try { |
| | | const text = String(reader.result || ""); |
| | | const parsed = JSON.parse(text); |
| | | const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data; |
| | | if (!Array.isArray(arr) || !arr.length) { |
| | | proxy?.$modal?.msgWarning?.("导å
¥æä»¶æ ¼å¼ä¸æ£ç¡®ï¼é为å çç³è¯·å¯¹è±¡æ°ç» JSON"); |
| | | return; |
| | | } |
| | | let n = 0; |
| | | for (let i = 0; i < arr.length; i++) { |
| | | allRows.value.unshift(normalizeImportedRow(arr[i], i)); |
| | | n++; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.(`æå导å
¥ ${n} æ¡ï¼æ¬å°åå¹¶ï¼`); |
| | | handleQuery(); |
| | | } catch { |
| | | proxy?.$modal?.msgError?.("è§£æå¤±è´¥ï¼è¯·ä½¿ç¨å¯¼åºæä»¶æçº¦å® JSON ç»æ"); |
| | | } |
| | | }; |
| | | reader.readAsText(file, "utf-8"); |
| | | } |
| | | |
| | | async function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.title = mode === "add" ? "æ°å¢å çç³è¯·" : "ç¼è¾å çç³è¯·"; |
| | | if (!allUsersCache.value.length) { |
| | | await loadUserPool(); |
| | | } |
| | | Object.assign(form, createEmptyForm()); |
| | | if (mode === "edit" && row) { |
| | | Object.assign(form, { |
| | | id: row.id, |
| | | applicantId: row.applicantId, |
| | | applicantNo: row.applicantNo, |
| | | applicantName: row.applicantName, |
| | | overtimeType: row.overtimeType, |
| | | overtimeDate: row.overtimeDate, |
| | | overtimeStartTime: row.overtimeStartTime, |
| | | overtimeEndTime: row.overtimeEndTime, |
| | | overtimeReason: row.overtimeReason, |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])), |
| | | approvalFlowNodes: row.approvalFlowNodes?.length |
| | | ? JSON.parse(JSON.stringify(row.approvalFlowNodes)) |
| | | : [], |
| | | }); |
| | | const u = userById(row.applicantId); |
| | | if (u) { |
| | | applicantFormOptions.value = [u]; |
| | | } else if (row.applicantId) { |
| | | applicantFormOptions.value = [ |
| | | { |
| | | userId: row.applicantId, |
| | | nickName: row.applicantName, |
| | | userName: row.applicantNo, |
| | | }, |
| | | ]; |
| | | } |
| | | } else { |
| | | remoteSearchApplicantForm(""); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => formRef.value?.clearValidate?.()); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | const hours = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime); |
| | | if (hours == null) { |
| | | proxy?.$modal?.msgWarning?.("è¯·æ£æ¥å çèµ·æ¢æ¶é´ï¼ç»ææ¶é´é¡»æäºå¼å§æ¶é´"); |
| | | return; |
| | | } |
| | | const payload = { |
| | | applicantId: form.applicantId, |
| | | applicantNo: form.applicantNo, |
| | | applicantName: form.applicantName, |
| | | overtimeType: form.overtimeType, |
| | | overtimeDate: form.overtimeDate, |
| | | overtimeStartTime: form.overtimeStartTime, |
| | | overtimeEndTime: form.overtimeEndTime, |
| | | overtimeHours: hours, |
| | | overtimeReason: form.overtimeReason, |
| | | approvalFlowNodes: (form.approvalFlowNodes || []).map((n, i) => ({ |
| | | approverId: n.approverId, |
| | | approverName: |
| | | n.approverName || userById(n.approverId)?.nickName || userById(n.approverId)?.userName || "", |
| | | sortOrder: i + 1, |
| | | nodeOrder: i + 1, |
| | | roleName: n.roleName || "", |
| | | roleCode: n.roleCode || "", |
| | | })), |
| | | attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])), |
| | | }; |
| | | if (formDialog.mode === "add") { |
| | | const id = `local_${Date.now()}`; |
| | | allRows.value.unshift({ |
| | | id, |
| | | ...payload, |
| | | approvalResult: "pending", |
| | | 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, |
| | | id: form.id, |
| | | ...payload, |
| | | approvalResult: prev.approvalResult ?? "pending", |
| | | createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"), |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_actions { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | 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; |
| | | } |
| | | .upload-block { |
| | | width: 100%; |
| | | } |
| | | .mr6 { |
| | | margin-right: 6px; |
| | | } |
| | | .mb6 { |
| | | margin-bottom: 6px; |
| | | } |
| | | .overtime-apply-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | .overtime-apply-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .overtime-apply-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | .flow-tip { |
| | | margin: 10px 0 0; |
| | | font-size: 12px; |
| | | line-height: 1.5; |
| | | color: var(--el-text-color-secondary); |
| | | } |
| | | .detail-flow-chain { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 6px 8px; |
| | | line-height: 1.6; |
| | | } |
| | | .detail-flow-step { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-primary); |
| | | } |
| | | .detail-flow-sep { |
| | | color: var(--el-text-color-secondary); |
| | | font-size: 13px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | 模å䏿åï¼éè´åå |
| | | ç®å½æ è¯ï¼ContractManage/purchase-contractï¼purchase-contract â 䏿ï¼éè´ååï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue' |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | 模å䏿åï¼éå®åå |
| | | ç®å½æ è¯ï¼ContractManage/sale-contractï¼sale-contract â 䏿ï¼éå®ååï¼ |
| | | å¤ç¨é¡µé¢ï¼@/views/procurementManagement/procurementLedger/index.vueï¼éè´å°è´¦ï¼æä»¶å index.vue â å
¥å£é¡µï¼ |
| | | --> |
| | | <template> |
| | | <ProcurementLedger /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue' |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- 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, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å²ä½ç®¡ç--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch"> |
| | | <el-form-item label="å²ä½ç¼ç " prop="postCode"> |
| | | <el-input |
| | | v-model="queryParams.postCode" |
| | | placeholder="请è¾å
¥å²ä½ç¼ç " |
| | | clearable |
| | | style="width: 200px" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="å²ä½åç§°" prop="postName"> |
| | | <el-input |
| | | v-model="queryParams.postName" |
| | | placeholder="请è¾å
¥å²ä½åç§°" |
| | | clearable |
| | | style="width: 200px" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="ç¶æ" prop="status"> |
| | | <el-select v-model="queryParams.status" placeholder="å²ä½ç¶æ" clearable style="width: 200px"> |
| | | <el-option |
| | | v-for="dict in sys_normal_disable" |
| | | :key="dict.value" |
| | | :label="dict.label" |
| | | :value="dict.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" icon="Search" @click="handleQuery">æç´¢</el-button> |
| | | <el-button icon="Refresh" @click="resetQuery">éç½®</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <el-row :gutter="10" class="mb8"> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="primary" |
| | | plain |
| | | icon="Plus" |
| | | @click="handleAdd" |
| | | v-hasPermi="['system:post:add']" |
| | | >æ°å¢</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="success" |
| | | plain |
| | | icon="Edit" |
| | | :disabled="single" |
| | | @click="handleUpdate" |
| | | v-hasPermi="['system:post:edit']" |
| | | >ä¿®æ¹</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="danger" |
| | | plain |
| | | icon="Delete" |
| | | :disabled="multiple" |
| | | @click="handleDelete" |
| | | v-hasPermi="['system:post:remove']" |
| | | >å é¤</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="warning" |
| | | plain |
| | | icon="Download" |
| | | @click="handleExport" |
| | | v-hasPermi="['system:post:export']" |
| | | >导åº</el-button> |
| | | </el-col> |
| | | <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar> |
| | | </el-row> |
| | | |
| | | <el-table v-loading="loading" :data="postList" @selection-change="handleSelectionChange"> |
| | | <el-table-column type="selection" width="55" align="center" /> |
| | | <el-table-column label="å²ä½ç¼å·" align="center" prop="postId" /> |
| | | <el-table-column label="å²ä½ç¼ç " align="center" prop="postCode" /> |
| | | <el-table-column label="å²ä½åç§°" align="center" prop="postName" /> |
| | | <el-table-column label="å²ä½æåº" align="center" prop="postSort" /> |
| | | <el-table-column label="ç¶æ" align="center" prop="status"> |
| | | <template #default="scope"> |
| | | <dict-tag :options="sys_normal_disable" :value="scope.row.status" /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å建æ¶é´" align="center" prop="createTime" width="180"> |
| | | <template #default="scope"> |
| | | <span>{{ parseTime(scope.row.createTime) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="180" align="center" class-name="small-padding fixed-width"> |
| | | <template #default="scope"> |
| | | <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:post:edit']">ä¿®æ¹</el-button> |
| | | <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:post:remove']">å é¤</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <pagination |
| | | v-show="total > 0" |
| | | :total="total" |
| | | v-model:page="queryParams.pageNum" |
| | | v-model:limit="queryParams.pageSize" |
| | | @pagination="getList" |
| | | /> |
| | | |
| | | <!-- æ·»å æä¿®æ¹å²ä½å¯¹è¯æ¡ --> |
| | | <el-dialog :title="title" v-model="open" width="500px" append-to-body> |
| | | <el-form ref="postRef" :model="form" :rules="rules" label-width="80px"> |
| | | <el-form-item label="å²ä½åç§°" prop="postName"> |
| | | <el-input v-model="form.postName" placeholder="请è¾å
¥å²ä½åç§°" /> |
| | | </el-form-item> |
| | | <el-form-item label="å²ä½ç¼ç " prop="postCode"> |
| | | <el-input v-model="form.postCode" placeholder="请è¾å
¥ç¼ç åç§°" /> |
| | | </el-form-item> |
| | | <el-form-item label="å²ä½é¡ºåº" prop="postSort"> |
| | | <el-input-number v-model="form.postSort" controls-position="right" :min="0" /> |
| | | </el-form-item> |
| | | <el-form-item label="å²ä½ç¶æ" prop="status"> |
| | | <el-radio-group v-model="form.status"> |
| | | <el-radio |
| | | v-for="dict in sys_normal_disable" |
| | | :key="dict.value" |
| | | :value="dict.value" |
| | | >{{ dict.label }}</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | <el-form-item label="夿³¨" prop="remark"> |
| | | <el-input v-model="form.remark" type="textarea" placeholder="请è¾å
¥å
容" /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="cancel">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="Post"> |
| | | import { listPost, addPost, delPost, getPost, updatePost } from "@/api/system/post" |
| | | import {onMounted} from "vue"; |
| | | |
| | | const { proxy } = getCurrentInstance() |
| | | const { sys_normal_disable } = proxy.useDict("sys_normal_disable") |
| | | |
| | | const postList = ref([]) |
| | | const open = ref(false) |
| | | const loading = ref(true) |
| | | const showSearch = ref(true) |
| | | const ids = ref([]) |
| | | const single = ref(true) |
| | | const multiple = ref(true) |
| | | const total = ref(0) |
| | | const title = ref("") |
| | | |
| | | const data = reactive({ |
| | | form: {}, |
| | | queryParams: { |
| | | pageNum: 1, |
| | | pageSize: 10, |
| | | postCode: undefined, |
| | | postName: undefined, |
| | | status: undefined |
| | | }, |
| | | rules: { |
| | | postName: [{ required: true, message: "å²ä½åç§°ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | postCode: [{ required: true, message: "å²ä½ç¼ç ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | postSort: [{ required: true, message: "å²ä½é¡ºåºä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | } |
| | | }) |
| | | |
| | | const { queryParams, form, rules } = toRefs(data) |
| | | |
| | | /** æ¥è¯¢å²ä½å表 */ |
| | | function getList() { |
| | | loading.value = true |
| | | listPost(queryParams.value).then(response => { |
| | | postList.value = response.rows |
| | | total.value = response.total |
| | | loading.value = false |
| | | }) |
| | | } |
| | | |
| | | /** åæ¶æé® */ |
| | | function cancel() { |
| | | open.value = false |
| | | reset() |
| | | } |
| | | |
| | | /** 表åéç½® */ |
| | | function reset() { |
| | | form.value = { |
| | | postId: undefined, |
| | | postCode: undefined, |
| | | postName: undefined, |
| | | postSort: 0, |
| | | status: "0", |
| | | remark: undefined |
| | | } |
| | | proxy.resetForm("postRef") |
| | | } |
| | | |
| | | /** æç´¢æé®æä½ */ |
| | | function handleQuery() { |
| | | queryParams.value.pageNum = 1 |
| | | getList() |
| | | } |
| | | |
| | | /** éç½®æé®æä½ */ |
| | | function resetQuery() { |
| | | proxy.resetForm("queryRef") |
| | | handleQuery() |
| | | } |
| | | |
| | | /** å¤éæ¡é䏿°æ® */ |
| | | function handleSelectionChange(selection) { |
| | | ids.value = selection.map(item => item.postId) |
| | | single.value = selection.length != 1 |
| | | multiple.value = !selection.length |
| | | } |
| | | |
| | | /** æ°å¢æé®æä½ */ |
| | | function handleAdd() { |
| | | reset() |
| | | open.value = true |
| | | title.value = "æ·»å å²ä½" |
| | | } |
| | | |
| | | /** ä¿®æ¹æé®æä½ */ |
| | | function handleUpdate(row) { |
| | | reset() |
| | | const postId = row.postId || ids.value |
| | | getPost(postId).then(response => { |
| | | form.value = response.data |
| | | open.value = true |
| | | title.value = "ä¿®æ¹å²ä½" |
| | | }) |
| | | } |
| | | |
| | | /** æäº¤æé® */ |
| | | function submitForm() { |
| | | proxy.$refs["postRef"].validate(valid => { |
| | | if (valid) { |
| | | if (form.value.postId != undefined) { |
| | | updatePost(form.value).then(response => { |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æå") |
| | | open.value = false |
| | | getList() |
| | | }) |
| | | } else { |
| | | addPost(form.value).then(response => { |
| | | proxy.$modal.msgSuccess("æ°å¢æå") |
| | | open.value = false |
| | | getList() |
| | | }) |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** å é¤æé®æä½ */ |
| | | function handleDelete(row) { |
| | | const postIds = row.postId || ids.value |
| | | proxy.$modal.confirm('æ¯å¦ç¡®è®¤å é¤å²ä½ç¼å·ä¸º"' + postIds + '"çæ°æ®é¡¹ï¼').then(function() { |
| | | return delPost(postIds) |
| | | }).then(() => { |
| | | getList() |
| | | proxy.$modal.msgSuccess("å 餿å") |
| | | }).catch(() => {}) |
| | | } |
| | | |
| | | /** å¯¼åºæé®æä½ */ |
| | | function handleExport() { |
| | | proxy.download("system/post/export", { |
| | | ...queryParams.value |
| | | }, `post_${new Date().getTime()}.xlsx`) |
| | | } |
| | | |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | </script> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼è½¬æ£ç³è¯·--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">ç³è¯·äººï¼</span> |
| | | <el-input |
| | | v-model="searchForm.applicantName" |
| | | 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.applyDateRange" |
| | | 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" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div> |
| | | <el-button type="primary" @click="openFormDialog('add')">æ°å¢è½¬æ£ç³è¯·</el-button> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="960px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="regular-apply-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="regular-apply-form"> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç³è¯·äºº" prop="applicantName"> |
| | | <el-input v-model="form.applicantName" placeholder="请è¾å
¥ç³è¯·äºº" maxlength="50" show-word-limit /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç³è¯·æ¥æ" prop="applyDate"> |
| | | <el-date-picker |
| | | v-model="form.applyDate" |
| | | type="date" |
| | | placeholder="è¯·éæ©ç³è¯·æ¥æ" |
| | | format="YYYY-MM-DD" |
| | | value-format="YYYY-MM-DD" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è½¬æ£æ¥æ" prop="regularizationDate"> |
| | | <el-date-picker |
| | | v-model="form.regularizationDate" |
| | | 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="å®¡æ¹æ¹å¼" prop="approvalMode"> |
| | | <el-radio-group v-model="form.approvalMode"> |
| | | <el-radio value="parallel">ä¸ç¾</el-radio> |
| | | <el-radio value="countersign">ä¼ç¾</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="审æ¹äºº" prop="approverIds"> |
| | | <el-tree-select |
| | | v-model="form.approverIds" |
| | | :data="approverTreeData" |
| | | multiple |
| | | collapse-tags |
| | | collapse-tags-tooltip |
| | | :max-collapse-tags="2" |
| | | :render-after-expand="false" |
| | | placeholder="è¯·éæ©å®¡æ¹äººï¼å¯å¤éï¼" |
| | | style="width: 100%" |
| | | :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }" |
| | | check-strictly |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="è¯ç¨æå·¥ä½æ»ç»" prop="probationSummary"> |
| | | <el-input |
| | | v-model="form.probationSummary" |
| | | type="textarea" |
| | | :rows="4" |
| | | placeholder="请填åè¯ç¨æå·¥ä½æ»ç»" |
| | | maxlength="2000" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="éä»¶"> |
| | | <div class="upload-block"> |
| | | <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="ç¹å»éæ©æä»¶" /> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
ï¼åªè¯»ï¼ --> |
| | | <el-dialog v-model="detailDialog.visible" title="转æ£ç³è¯·è¯¦æ
" width="640px" append-to-body> |
| | | <el-descriptions :column="1" border> |
| | | <el-descriptions-item label="ç³è¯·äºº">{{ detailRow.applicantName }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç³è¯·æ¥æ">{{ detailRow.applyDate }}</el-descriptions-item> |
| | | <el-descriptions-item label="è½¬æ£æ¥æ">{{ detailRow.regularizationDate }}</el-descriptions-item> |
| | | <el-descriptions-item label="è¯ç¨æå·¥ä½æ»ç»">{{ detailRow.probationSummary }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç»æ">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ¹æ¹å¼">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹äºº">{{ detailRow.approverNames || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="éä»¶"> |
| | | <template v-if="detailRow.attachmentList?.length"> |
| | | <el-tag |
| | | v-for="(f, i) in detailRow.attachmentList" |
| | | :key="i" |
| | | class="mr6 mb6" |
| | | type="info" |
| | | > |
| | | {{ f.name }} |
| | | </el-tag> |
| | | </template> |
| | | <span v-else>æ </span> |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- éä»¶å表 --> |
| | | <el-dialog v-model="filesDialog.visible" title="éä»¶" width="520px" append-to-body> |
| | | <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border> |
| | | <el-table-column type="index" label="åºå·" width="60" align="center" /> |
| | | <el-table-column prop="name" label="æä»¶å" min-width="200" show-overflow-tooltip /> |
| | | <el-table-column label="æä½" width="100" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button link type="primary" @click="mockDownload(row)">ä¸è½½</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <el-empty v-else description="ææ éä»¶" /> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="filesDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | |
| | | /** ä¸å端约å®å段ï¼å ä½ï¼ */ |
| | | const createEmptyForm = () => ({ |
| | | id: undefined, |
| | | applicantName: "", |
| | | applyDate: "", |
| | | regularizationDate: "", |
| | | probationSummary: "", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | attachmentList: [], |
| | | }); |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | /** 审æ¹äººæ ï¼é¨é¨æ + ç³»ç»ç¨æ·ï¼ä¸ staff-archive / user-manage åæºæ¥å£ï¼ */ |
| | | const approverTreeData = ref([]); |
| | | const approverLabelMap = ref({}); |
| | | |
| | | /** æ¥å£è¿åç»ä¸æææ°ç»ï¼å
¼å®¹ axios æ¦æªå¨å·²è§£å
为 { data } æç´æ¥æ°ç»çæ
åµï¼ */ |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload && Array.isArray(payload.data)) return payload.data; |
| | | if (payload && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function filterDisabledDept(deptList) { |
| | | if (!Array.isArray(deptList)) return []; |
| | | return deptList.filter((dept) => { |
| | | if (dept.disabled) return false; |
| | | if (dept.children?.length) { |
| | | dept.children = filterDisabledDept(dept.children); |
| | | } |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | function getUserDeptId(u) { |
| | | return ( |
| | | u.deptId ?? |
| | | u.sysDeptId ?? |
| | | u.dept?.deptId ?? |
| | | u.dept?.id ?? |
| | | u.dept_id |
| | | ); |
| | | } |
| | | |
| | | /** é¨é¨æ èç¹ä¸»é®ï¼è¥ä¾ä¸è¬ä¸º idï¼é¨ååºæ¯ä¸º valueï¼ */ |
| | | function getDeptNodeKey(node) { |
| | | const k = node?.id ?? node?.value ?? node?.deptId; |
| | | if (k == null || k === "") return null; |
| | | return k; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | function userToTreeLeaf(u) { |
| | | return { |
| | | id: String(u.userId ?? u.id), |
| | | label: u.nickName || u.userName || `ç¨æ·${u.userId ?? u.id}`, |
| | | }; |
| | | } |
| | | |
| | | /** æé¨é¨ id åç»ï¼æ é¨é¨æ id 为 0 çç¨æ·è¿å
¥æªåé
å表 */ |
| | | function buildUsersByDeptId(users) { |
| | | const map = new Map(); |
| | | const unassigned = []; |
| | | for (const u of users) { |
| | | if (!isActiveUser(u)) continue; |
| | | const did = getUserDeptId(u); |
| | | if (did == null || did === "" || did === 0 || did === "0") { |
| | | unassigned.push(u); |
| | | continue; |
| | | } |
| | | const k = String(did); |
| | | if (!map.has(k)) map.set(k, []); |
| | | map.get(k).push(u); |
| | | } |
| | | return { map, unassigned }; |
| | | } |
| | | |
| | | function collectUserLabels(nodes, map) { |
| | | (nodes || []).forEach((n) => { |
| | | if (n.children?.length) { |
| | | collectUserLabels(n.children, map); |
| | | } else if (n.id != null && !String(n.id).startsWith("dept_")) { |
| | | map[String(n.id)] = n.label; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** é¨é¨èç¹ id å åç¼ï¼é¿å
ä¸ userId æ°å¼å²çªï¼å¯éèç¹ä¸ºçå® userId å符串 */ |
| | | function mergeDeptTreeWithUsers(nodes, usersByDept) { |
| | | if (!Array.isArray(nodes)) return []; |
| | | const out = []; |
| | | for (const node of nodes) { |
| | | const deptIdRaw = getDeptNodeKey(node); |
| | | if (deptIdRaw == null) continue; |
| | | const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept); |
| | | const usersHere = usersByDept.get(String(deptIdRaw)) || []; |
| | | const userChildren = usersHere.map(userToTreeLeaf); |
| | | const children = [...sub, ...userChildren]; |
| | | if (!children.length) continue; |
| | | out.push({ |
| | | id: `dept_${deptIdRaw}`, |
| | | label: node.label ?? node.deptName ?? "é¨é¨", |
| | | disabled: true, |
| | | children, |
| | | }); |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | function buildFlatApproverTree(users) { |
| | | const list = users.filter(isActiveUser).map(userToTreeLeaf); |
| | | if (!list.length) return []; |
| | | return [ |
| | | { |
| | | id: "dept_all_users", |
| | | label: "ç³»ç»ç¨æ·", |
| | | disabled: true, |
| | | children: list, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | async function loadApproverTree() { |
| | | try { |
| | | const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]); |
| | | let rawTree = unwrapArray(deptRes); |
| | | rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : []; |
| | | let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree))); |
| | | if (!deptTree.length && rawTree.length) { |
| | | deptTree = JSON.parse(JSON.stringify(rawTree)); |
| | | } |
| | | const users = unwrapArray(userRes); |
| | | const { map: usersByDept, unassigned } = buildUsersByDeptId(users); |
| | | let merged = mergeDeptTreeWithUsers(deptTree, usersByDept); |
| | | if (unassigned.length) { |
| | | merged.push({ |
| | | id: "dept_unassigned", |
| | | label: "æªåé
é¨é¨", |
| | | disabled: true, |
| | | children: unassigned.map(userToTreeLeaf), |
| | | }); |
| | | } |
| | | if (!merged.length && users.length) { |
| | | merged = buildFlatApproverTree(users); |
| | | } |
| | | approverTreeData.value = merged; |
| | | const map = {}; |
| | | collectUserLabels(merged, map); |
| | | approverLabelMap.value = map; |
| | | } catch { |
| | | approverTreeData.value = []; |
| | | approverLabelMap.value = {}; |
| | | proxy?.$modal?.msgWarning?.("审æ¹äººæ°æ®å 载失败ï¼è¯·æ£æ¥ç½ç»æç¨åéè¯"); |
| | | } |
| | | } |
| | | |
| | | function resolveApproverNames(ids) { |
| | | if (!ids?.length) return ""; |
| | | const map = approverLabelMap.value; |
| | | return ids.map((id) => map[String(id)] || id).join("ã"); |
| | | } |
| | | |
| | | function approvalModeLabel(mode) { |
| | | if (mode === "countersign") return "ä¼ç¾"; |
| | | return "ä¸ç¾"; |
| | | } |
| | | |
| | | function approvalResultLabel(v) { |
| | | if (v === "approved") return "å·²éè¿"; |
| | | if (v === "rejected") return "已驳å"; |
| | | if (v === "cancelled") return "å·²æ¤é"; |
| | | return "å¾
审æ¹"; |
| | | } |
| | | |
| | | /** æ¬å°æ¨¡ææ°æ®æº */ |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | applicantName: "卿", |
| | | applyDate: "2026-05-01", |
| | | regularizationDate: "2026-06-01", |
| | | probationSummary: "è¯ç¨æå
å®ææ¨¡åå¼åä¸èè°ï¼çæä¸å¡æµç¨ã", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | approvalResult: "pending", |
| | | attachmentList: [{ name: "工使»ç».pdf" }, { name: "èæ ¸è¡¨.xlsx" }], |
| | | }, |
| | | { |
| | | id: "2", |
| | | applicantName: "å´è³", |
| | | applyDate: "2026-05-08", |
| | | regularizationDate: "2026-06-10", |
| | | probationSummary: "宿å
¥èå¹è®ä¸å²ä½å®è·µï¼è¾¾å°å²ä½è¦æ±ã", |
| | | approvalMode: "countersign", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | approvalResult: "approved", |
| | | attachmentList: [], |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantName: "", |
| | | applyDateRange: null, |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | total: 0, |
| | | }); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | const name = (searchForm.applicantName || "").trim(); |
| | | if (name) { |
| | | list = list.filter((r) => r.applicantName.includes(name)); |
| | | } |
| | | const range = searchForm.applyDateRange; |
| | | if (range && range.length === 2) { |
| | | const [start, end] = range; |
| | | list = list.filter((r) => r.applyDate >= start && r.applyDate <= end); |
| | | } |
| | | return list.sort((a, b) => (a.applyDate < b.applyDate ? 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 list = filteredList.value; |
| | | const start = (page.current - 1) * page.size; |
| | | return list.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 100 }, |
| | | { label: "ç³è¯·æ¥æ", prop: "applyDate", width: 120 }, |
| | | { label: "è½¬æ£æ¥æ", prop: "regularizationDate", width: 120 }, |
| | | { label: "è¯ç¨æå·¥ä½æ»ç»", prop: "probationSummary", minWidth: 200 }, |
| | | { |
| | | label: "审æ¹ç»æ", |
| | | prop: "approvalResult", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => approvalResultLabel(v), |
| | | formatType: (v) => { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | if (v === "cancelled") return "info"; |
| | | return "warning"; |
| | | }, |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 200, |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | clickFun: (row) => openFormDialog("edit", row), |
| | | }, |
| | | { |
| | | name: "æ¥ç详æ
", |
| | | type: "text", |
| | | clickFun: (row) => openDetail(row), |
| | | }, |
| | | { |
| | | name: "éä»¶", |
| | | type: "text", |
| | | clickFun: (row) => openFiles(row), |
| | | }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formDialog = reactive({ |
| | | visible: false, |
| | | title: "", |
| | | mode: "add", |
| | | }); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | |
| | | const formRules = { |
| | | applicantName: [{ required: true, message: "请è¾å
¥ç³è¯·äºº", trigger: "blur" }], |
| | | applyDate: [{ required: true, message: "è¯·éæ©ç³è¯·æ¥æ", trigger: "change" }], |
| | | regularizationDate: [{ required: true, message: "è¯·éæ©è½¬æ£æ¥æ", trigger: "change" }], |
| | | probationSummary: [{ required: true, message: "请填åè¯ç¨æå·¥ä½æ»ç»", trigger: "blur" }], |
| | | approvalMode: [{ required: true, message: "è¯·éæ©å®¡æ¹æ¹å¼", trigger: "change" }], |
| | | approverIds: [ |
| | | { |
| | | type: "array", |
| | | required: true, |
| | | message: "è¯·éæ©å®¡æ¹äºº", |
| | | trigger: "change", |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | const filesDialog = reactive({ visible: false, row: null }); |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | function resetSearch() { |
| | | searchForm.applicantName = ""; |
| | | searchForm.applyDateRange = null; |
| | | handleQuery(); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function openFiles(row) { |
| | | filesDialog.row = row; |
| | | filesDialog.visible = true; |
| | | } |
| | | |
| | | function mockDownload(row) { |
| | | const url = row.url || row.downloadURL || row.previewURL || row.previewUrl; |
| | | if (url) { |
| | | window.open(url, "_blank"); |
| | | return; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.(`已模æä¸è½½ï¼${row.name}`); |
| | | } |
| | | |
| | | function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.title = mode === "add" ? "æ°å¢è½¬æ£ç³è¯·" : "ç¼è¾è½¬æ£ç³è¯·"; |
| | | loadApproverTree(); |
| | | Object.assign(form, createEmptyForm()); |
| | | if (mode === "edit" && row) { |
| | | Object.assign(form, { |
| | | id: row.id, |
| | | applicantName: row.applicantName, |
| | | applyDate: row.applyDate, |
| | | regularizationDate: row.regularizationDate, |
| | | probationSummary: row.probationSummary, |
| | | approvalMode: row.approvalMode, |
| | | approverIds: (row.approverIds || []).map((id) => String(id)), |
| | | approverNames: row.approverNames, |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])), |
| | | }); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => formRef.value?.clearValidate?.()); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | form.approverNames = resolveApproverNames(form.approverIds); |
| | | const payload = { |
| | | applicantName: form.applicantName, |
| | | applyDate: form.applyDate, |
| | | regularizationDate: form.regularizationDate, |
| | | probationSummary: form.probationSummary, |
| | | approvalMode: form.approvalMode, |
| | | approverIds: [...form.approverIds], |
| | | approverNames: form.approverNames, |
| | | attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])), |
| | | }; |
| | | if (formDialog.mode === "add") { |
| | | const id = `local_${Date.now()}`; |
| | | allRows.value.unshift({ id, ...payload, approvalResult: "pending" }); |
| | | 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, |
| | | id: form.id, |
| | | ...payload, |
| | | approvalResult: prev.approvalResult ?? "pending", |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadApproverTree(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_title { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-regular); |
| | | } |
| | | .upload-block { |
| | | width: 100%; |
| | | } |
| | | .mr6 { |
| | | margin-right: 6px; |
| | | } |
| | | .mb6 { |
| | | margin-bottom: 6px; |
| | | } |
| | | .regular-apply-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | .regular-apply-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .regular-apply-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div> |
| | | <el-dialog |
| | | v-model="dialogFormVisible" |
| | | :title="operationType === 'add' ? 'æ°å¢ç¦»è' : 'ç¼è¾ç¦»è'" |
| | | width="70%" |
| | | @close="closeDia" |
| | | > |
| | | <!-- å工信æ¯å±ç¤ºåºå --> |
| | | <div class="info-section"> |
| | | <div class="info-title">å工信æ¯</div> |
| | | <el-form :model="form" label-width="200px" label-position="left" :rules="rules" ref="formRef" style="margin-top: 20px"> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å§åï¼" prop="staffOnJobId"> |
| | | <el-select v-model="form.staffOnJobId" |
| | | placeholder="è¯·éæ©äººå" |
| | | style="width: 100%" |
| | | :disabled="operationType === 'edit'" |
| | | @change="handleSelect"> |
| | | <el-option |
| | | v-for="item in personList" |
| | | :key="item.id" |
| | | :label="item.staffName" |
| | | :value="item.id" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="åå·¥ç¼å·ï¼"> |
| | | {{ currentStaffRecord.staffNo || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ§å«ï¼"> |
| | | {{ currentStaffRecord.sex || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ·ç±ä½åï¼"> |
| | | {{ currentStaffRecord.nativePlace || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å²ä½ï¼"> |
| | | {{ currentStaffRecord.postName || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç°ä½åï¼"> |
| | | {{ currentStaffRecord.adress || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="第ä¸å¦åï¼"> |
| | | {{ currentStaffRecord.firstStudy || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ä¸ä¸ï¼"> |
| | | {{ currentStaffRecord.profession || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å¹´é¾ï¼"> |
| | | {{ currentStaffRecord.age || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="èç³»çµè¯ï¼"> |
| | | {{ currentStaffRecord.phone || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç´§æ¥è系人ï¼"> |
| | | {{ currentStaffRecord.emergencyContact || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç´§æ¥è系人èç³»çµè¯ï¼"> |
| | | {{ currentStaffRecord.emergencyContactPhone || '-' }} |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç¦»èæ¥æï¼" prop="leaveDate"> |
| | | <el-date-picker |
| | | v-model="form.leaveDate" |
| | | type="date" |
| | | :disabled="operationType === 'edit'" |
| | | :disabled-date="disabledFutureDate" |
| | | placeholder="è¯·éæ©ç¦»èæ¥æ" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="离èåå ï¼" prop="reason"> |
| | | <el-select v-model="form.reason" placeholder="è¯·éæ©ç¦»èåå " style="width: 100%" @change="handleSelectDimissionReason"> |
| | | <el-option |
| | | v-for="(item, index) in dimissionReasonOptions" |
| | | :key="index" |
| | | :label="item.label" |
| | | :value="item.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="30"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="夿³¨ï¼" prop="remark" v-if="form.reason === 'other'"> |
| | | <el-input |
| | | v-model="form.remark" |
| | | type="textarea" |
| | | :rows="3" |
| | | placeholder="夿³¨" |
| | | maxlength="500" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | |
| | | <!-- <el-row :gutter="30">--> |
| | | <!-- <el-col :span="12">--> |
| | | <!-- <div class="info-item">--> |
| | | <!-- <span class="info-label">离èåå ï¼</span>--> |
| | | <!-- <el-select v-model="form.reason" placeholder="è¯·éæ©äººå" style="width: 100%" @change="handleSelect">--> |
| | | <!-- <el-option--> |
| | | <!-- v-for="(item, index) in dimissionReasonOptions"--> |
| | | <!-- :key="index"--> |
| | | <!-- :label="item.label"--> |
| | | <!-- :value="item.value"--> |
| | | <!-- />--> |
| | | <!-- </el-select>--> |
| | | <!-- </div>--> |
| | | <!-- </el-col>--> |
| | | <!-- <el-col :span="12">--> |
| | | <!-- <div class="info-item">--> |
| | | <!-- <span class="info-label">åå·¥ç¼å·ï¼</span>--> |
| | | <!-- <span class="info-value">{{ form.staffNo || '-' }}</span>--> |
| | | <!-- </div>--> |
| | | <!-- </el-col>--> |
| | | <!-- </el-row>--> |
| | | </div> |
| | | |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">确认</el-button> |
| | | <el-button @click="closeDia">åæ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {ref, reactive, toRefs, getCurrentInstance} from "vue"; |
| | | import {staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js"; |
| | | import {createStaffLeave, updateStaffLeave} from "@/api/personnelManagement/staffLeave.js"; |
| | | const { proxy } = getCurrentInstance() |
| | | const emit = defineEmits(['close']) |
| | | |
| | | const dialogFormVisible = ref(false); |
| | | const operationType = ref('') |
| | | const getTodayDate = () => { |
| | | const now = new Date(); |
| | | const year = now.getFullYear(); |
| | | const month = `${now.getMonth() + 1}`.padStart(2, '0'); |
| | | const day = `${now.getDate()}`.padStart(2, '0'); |
| | | return `${year}-${month}-${day}`; |
| | | }; |
| | | |
| | | const disabledFutureDate = (time) => { |
| | | const todayEnd = new Date(); |
| | | todayEnd.setHours(23, 59, 59, 999); |
| | | return time.getTime() > todayEnd.getTime(); |
| | | }; |
| | | const data = reactive({ |
| | | form: { |
| | | staffOnJobId: undefined, |
| | | leaveDate: "", |
| | | reason: "", |
| | | remark: "", |
| | | }, |
| | | rules: { |
| | | staffName: [{ required: true, message: "è¯·éæ©äººå" }], |
| | | leaveDate: [{ required: true, message: "è¯·éæ©ç¦»èæ¥æ", trigger: "change" }], |
| | | reason: [{ required: true, message: "è¯·éæ©ç¦»èåå "}], |
| | | }, |
| | | dimissionReasonOptions: [ |
| | | {label: 'èªèµå¾
é', value: 'salary'}, |
| | | {label: 'èä¸åå±', value: 'career_development'}, |
| | | {label: 'å·¥ä½ç¯å¢', value: 'work_environment'}, |
| | | {label: '个人åå ', value: 'personal_reason'}, |
| | | {label: 'å
¶ä»', value: 'other'}, |
| | | ], |
| | | currentStaffRecord: {}, |
| | | }); |
| | | const { form, rules, dimissionReasonOptions, currentStaffRecord } = toRefs(data); |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openDialog = (type, row) => { |
| | | operationType.value = type; |
| | | dialogFormVisible.value = true; |
| | | if (operationType.value === 'edit') { |
| | | currentStaffRecord.value = row |
| | | form.value.staffOnJobId = row.staffOnJobId |
| | | form.value.leaveDate = row.leaveDate |
| | | form.value.reason = row.reason |
| | | form.value.remark = row.remark |
| | | personList.value = [ |
| | | { |
| | | staffName: row.staffName, |
| | | id: row.staffOnJobId, |
| | | } |
| | | ] |
| | | } else { |
| | | form.value.leaveDate = getTodayDate() |
| | | getList() |
| | | } |
| | | } |
| | | |
| | | const handleSelectDimissionReason = (val) => { |
| | | if (val === 'other') { |
| | | form.value.remark = '' |
| | | } |
| | | } |
| | | // æäº¤äº§å表å |
| | | const submitForm = () => { |
| | | form.value.staffState = 0 |
| | | if (form.value.reason !== 'other') { |
| | | form.value.remark = '' |
| | | } |
| | | proxy.$refs["formRef"].validate(valid => { |
| | | if (valid) { |
| | | if (operationType.value === "add") { |
| | | createStaffLeave(form.value).then(res => { |
| | | proxy.$modal.msgSuccess("æäº¤æå"); |
| | | closeDia(); |
| | | }) |
| | | } else { |
| | | updateStaffLeave(currentStaffRecord.value.id, form.value).then(res => { |
| | | proxy.$modal.msgSuccess("æäº¤æå"); |
| | | closeDia(); |
| | | }) |
| | | } |
| | | } |
| | | }) |
| | | |
| | | } |
| | | // å
³éå¼¹æ¡ |
| | | const closeDia = () => { |
| | | // 表å已注éï¼æå¨éç½®è¡¨åæ°æ® |
| | | form.value = { |
| | | staffOnJobId: undefined, |
| | | leaveDate: "", |
| | | reason: "", |
| | | remark: "", |
| | | }; |
| | | dialogFormVisible.value = false; |
| | | emit('close') |
| | | }; |
| | | |
| | | const personList = ref([]); |
| | | |
| | | /** |
| | | * è·åå½åå¨è人åå表 |
| | | */ |
| | | const getList = () => { |
| | | staffOnJobListPage({ |
| | | current: -1, |
| | | size: -1, |
| | | staffState: 1 |
| | | }).then(res => { |
| | | personList.value = res.data.records || [] |
| | | }) |
| | | }; |
| | | |
| | | const handleSelect = (val) => { |
| | | let obj = personList.value.find(item => item.id === val) |
| | | currentStaffRecord.value = {} |
| | | if (obj) { |
| | | // ä¿çç¦»èæ¥æå离èåå ï¼åªæ´æ°åå·¥ä¿¡æ¯ |
| | | currentStaffRecord.value = obj |
| | | } |
| | | } |
| | | defineExpose({ |
| | | openDialog, |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .info-section { |
| | | background: #f5f7fa; |
| | | padding: 20px; |
| | | border-radius: 8px; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .info-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | margin-bottom: 20px; |
| | | padding-bottom: 10px; |
| | | border-bottom: 1px solid #e4e7ed; |
| | | } |
| | | |
| | | .info-item { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 16px; |
| | | min-height: 32px; |
| | | } |
| | | |
| | | .info-label { |
| | | min-width: 140px; |
| | | color: #606266; |
| | | font-size: 14px; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .info-value { |
| | | flex: 1; |
| | | color: #303133; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼ç¦»èç³è¯·--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">å§åï¼</span> |
| | | <el-input |
| | | v-model="searchForm.staffName" |
| | | style="width: 240px" |
| | | placeholder="请è¾å
¥å§åæç´¢" |
| | | @change="handleQuery" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | /> |
| | | <el-button type="primary" @click="handleQuery" style="margin-left: 10px" |
| | | >æç´¢</el-button |
| | | > |
| | | </div> |
| | | <div> |
| | | <el-button type="primary" @click="openForm('add')">æ°å¢ç¦»è</el-button> |
| | | <el-button @click="handleOut">导åº</el-button> |
| | | <!-- <el-button type="danger" plain @click="handleDelete">å é¤</el-button> --> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="true" |
| | | @selection-change="handleSelectionChange" |
| | | :tableLoading="tableLoading" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | ></PIMTable> |
| | | </div> |
| | | <form-dia ref="formDia" @close="handleQuery"></form-dia> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import {onMounted, ref} from "vue"; |
| | | import FormDia from "@/views/personnelManagement/dimission/components/formDia.vue"; |
| | | import {findStaffLeaveListPage, batchDeleteStaffLeaves} from "@/api/personnelManagement/staffLeave.js"; |
| | | import {ElMessageBox} from "element-plus"; |
| | | |
| | | const data = reactive({ |
| | | searchForm: { |
| | | staffName: "", |
| | | }, |
| | | }); |
| | | const { searchForm } = toRefs(data); |
| | | const tableColumn = ref([ |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "staffState", |
| | | dataType: "tag", |
| | | formatData: (params) => { |
| | | if (params == 0) { |
| | | return "离è"; |
| | | } else if (params == 1) { |
| | | return "å¨è"; |
| | | } else { |
| | | return null; |
| | | } |
| | | }, |
| | | formatType: (params) => { |
| | | if (params == 0) { |
| | | return "danger"; |
| | | } else if (params == 1) { |
| | | return "primary"; |
| | | } else { |
| | | return null; |
| | | } |
| | | }, |
| | | }, |
| | | { |
| | | label: "ç¦»èæ¥æ", |
| | | prop: "leaveDate", |
| | | }, |
| | | { |
| | | label: "åå·¥ç¼å·", |
| | | prop: "staffNo", |
| | | }, |
| | | { |
| | | label: "å§å", |
| | | prop: "staffName", |
| | | }, |
| | | { |
| | | label: "æ§å«", |
| | | prop: "sex", |
| | | }, |
| | | { |
| | | label: "æ·ç±ä½å", |
| | | prop: "nativePlace", |
| | | }, |
| | | { |
| | | label: "é¨é¨", |
| | | prop: "deptName", |
| | | }, |
| | | { |
| | | label: "å²ä½", |
| | | prop: "postName", |
| | | }, |
| | | { |
| | | label: "ç°ä½å", |
| | | prop: "adress", |
| | | width:200 |
| | | }, |
| | | { |
| | | label: "第ä¸å¦å", |
| | | prop: "firstStudy", |
| | | }, |
| | | { |
| | | label: "ä¸ä¸", |
| | | prop: "profession", |
| | | width:100 |
| | | }, |
| | | { |
| | | label: "å¹´é¾", |
| | | prop: "age", |
| | | }, |
| | | { |
| | | label: "èç³»çµè¯", |
| | | prop: "phone", |
| | | width:150 |
| | | }, |
| | | { |
| | | label: "ç´§æ¥è系人", |
| | | prop: "emergencyContact", |
| | | width: 120 |
| | | }, |
| | | { |
| | | label: "ç´§æ¥è系人çµè¯", |
| | | prop: "emergencyContactPhone", |
| | | width:150 |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: 'right', |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | openForm("edit", row); |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | ]); |
| | | const tableData = ref([]); |
| | | const selectedRows = ref([]); |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 100, |
| | | total: 0, |
| | | }); |
| | | const formDia = ref() |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | |
| | | // æ¥è¯¢å表 |
| | | /** æç´¢æé®æä½ */ |
| | | const handleQuery = () => { |
| | | page.current = 1; |
| | | getList(); |
| | | }; |
| | | const pagination = (obj) => { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | getList(); |
| | | }; |
| | | const getList = () => { |
| | | tableLoading.value = true; |
| | | findStaffLeaveListPage({...page, ...searchForm.value}).then(res => { |
| | | tableLoading.value = false; |
| | | tableData.value = res.data.records |
| | | page.total = res.data.total; |
| | | }).catch(err => { |
| | | tableLoading.value = false; |
| | | }) |
| | | }; |
| | | // è¡¨æ ¼éæ©æ°æ® |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.value = selection; |
| | | }; |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openForm = (type, row) => { |
| | | nextTick(() => { |
| | | formDia.value?.openDialog(type, row) |
| | | }) |
| | | }; |
| | | |
| | | // å é¤ |
| | | const handleDelete = () => { |
| | | let ids = []; |
| | | if (selectedRows.value.length > 0) { |
| | | ids = selectedRows.value.map((item) => item.id); |
| | | } else { |
| | | proxy.$modal.msgWarning("è¯·éæ©æ°æ®"); |
| | | return; |
| | | } |
| | | ElMessageBox.confirm("éä¸çå
容å°è¢«å é¤ï¼æ¯å¦ç¡®è®¤å é¤ï¼", "导åº", { |
| | | confirmButtonText: "确认", |
| | | cancelButtonText: "åæ¶", |
| | | type: "warning", |
| | | }) |
| | | .then(() => { |
| | | batchDeleteStaffLeaves(ids).then((res) => { |
| | | proxy.$modal.msgSuccess("å 餿å"); |
| | | getList(); |
| | | }); |
| | | }) |
| | | .catch(() => { |
| | | proxy.$modal.msg("已忶"); |
| | | }); |
| | | }; |
| | | // å¯¼åº |
| | | const handleOut = () => { |
| | | ElMessageBox.confirm("éä¸çå
容å°è¢«å¯¼åºï¼æ¯å¦ç¡®è®¤å¯¼åºï¼", "导åº", { |
| | | confirmButtonText: "确认", |
| | | cancelButtonText: "åæ¶", |
| | | type: "warning", |
| | | }) |
| | | .then(() => { |
| | | proxy.download("/staff/staffLeave/export", {}, "人å离è.xlsx"); |
| | | }) |
| | | .catch(() => { |
| | | proxy.$modal.msg("已忶"); |
| | | }); |
| | | }; |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped></style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-card class="form-card" shadow="never"> |
| | | <template #header> |
| | | <span class="card-title"> |
| | | <span class="card-title-line">|</span> |
| | | åºæ¬ä¿¡æ¯ |
| | | </span> |
| | | </template> |
| | | |
| | | <el-row :gutter="24"> |
| | | <el-col :span="5"> |
| | | <el-form-item label="åå·¥ç¼å·" prop="staffNo"> |
| | | <el-input |
| | | v-model="form.staffNo" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | :disabled="operationType !== 'add'" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="5"> |
| | | <el-form-item label="å§å" prop="staffName"> |
| | | <el-input |
| | | v-model="form.staffName" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="50" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="5"> |
| | | <el-form-item label="å«å" prop="alias"> |
| | | <el-input |
| | | v-model="form.alias" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="50" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="5"> |
| | | <el-form-item label="ææº" prop="phone"> |
| | | <el-input |
| | | v-model="form.phone" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="11" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="4"> |
| | | <el-form-item label="æ§å«" prop="sex"> |
| | | <el-select |
| | | v-model="form.sex" |
| | | placeholder="è¯·éæ©" |
| | | clearable |
| | | style="width: 100%" |
| | | > |
| | | <el-option label="ç·" value="ç·" /> |
| | | <el-option label="女" value="女" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="24"> |
| | | <el-col :span="5"> |
| | | <el-form-item label="åºçæ¥æ" prop="birthDate"> |
| | | <el-date-picker |
| | | v-model="form.birthDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="5"> |
| | | <el-form-item label="å¹´é¾" prop="age"> |
| | | <el-input-number |
| | | v-model="form.age" |
| | | :min="0" |
| | | :max="150" |
| | | :precision="0" |
| | | :step="1" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="5"> |
| | | <el-form-item label="ç±è´¯" prop="nativePlace"> |
| | | <el-input |
| | | v-model="form.nativePlace" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="50" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="5"> |
| | | <el-form-item label="æ°æ" prop="nation"> |
| | | <el-input |
| | | v-model="form.nation" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="4"> |
| | | <el-form-item label="å©å§»ç¶åµ" prop="maritalStatus"> |
| | | <el-select |
| | | v-model="form.maritalStatus" |
| | | placeholder="è¯·éæ©" |
| | | clearable |
| | | style="width: 100%" |
| | | > |
| | | <el-option label="æªå©" value="æªå©" /> |
| | | <el-option label="å·²å©" value="å·²å©" /> |
| | | <el-option label="离å¼" value="离å¼" /> |
| | | <el-option label="丧å¶" value="丧å¶" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="24"> |
| | | <el-col :span="10"> |
| | | <el-form-item label="è§è²" prop="roleId"> |
| | | <el-select |
| | | v-model="form.roleId" |
| | | placeholder="è¯·éæ©" |
| | | clearable |
| | | style="width: 100%" |
| | | > |
| | | <el-option |
| | | v-for="item in roleOptions" |
| | | :key="item.roleId" |
| | | :label="item.roleName" |
| | | :value="item.roleId" |
| | | :disabled="item.status == 1" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { toRefs } from "vue"; |
| | | |
| | | const props = defineProps({ |
| | | form: { type: Object, required: true }, |
| | | operationType: { type: String, default: "add" }, |
| | | roleOptions: { type: Array, default: () => [] }, |
| | | }); |
| | | |
| | | const { form, operationType, roleOptions } = toRefs(props); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-card { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .card-title-line { |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | } |
| | | </style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div> |
| | | <!-- æè²ç»å --> |
| | | <el-card class="form-card" shadow="never"> |
| | | <template #header> |
| | | <span class="card-title"> |
| | | <span class="card-title-line">|</span> |
| | | æè²ç»å |
| | | </span> |
| | | </template> |
| | | <el-table :data="form.staffEducationList" border> |
| | | <el-table-column label="å¦å" prop="education" width="120"> |
| | | <template #default="{ row }"> |
| | | <el-select |
| | | v-model="row.education" |
| | | placeholder="è¯·éæ©" |
| | | clearable |
| | | style="width: 100%" |
| | | > |
| | | <el-option label="ä¸ä¸å以ä¸" value="secondary" /> |
| | | <el-option label="大ä¸" value="junior_college" /> |
| | | <el-option label="æ¬ç§" value="bachelor" /> |
| | | <el-option label="ç¡å£«" value="master" /> |
| | | <el-option label="å士å以ä¸" value="doctor" /> |
| | | </el-select> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æ¯ä¸é¢æ ¡" prop="schoolName" min-width="160"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.schoolName" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="30" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å
¥å¦æ¶é´" prop="enrollTime" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-model="row.enrollTime" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æ¯ä¸æ¶é´" prop="graduateTime" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-model="row.graduateTime" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ä¸ä¸" prop="major" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.major" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å¦ä½" prop="degree" width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.degree" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="80" align="center"> |
| | | <template #default="scope"> |
| | | <el-button |
| | | v-if="form.staffEducationList.length > 1" |
| | | type="primary" |
| | | link |
| | | @click="removeEducationRow(scope.$index)" |
| | | > |
| | | å é¤ |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="table-add-row" @click="addEducationRow">æ°å»ºä¸è¡</div> |
| | | </el-card> |
| | | |
| | | <!-- å·¥ä½ç»å --> |
| | | <el-card class="form-card" shadow="never"> |
| | | <template #header> |
| | | <span class="card-title"> |
| | | <span class="card-title-line">|</span> |
| | | å·¥ä½ç»å |
| | | </span> |
| | | </template> |
| | | <el-table :data="form.staffWorkExperienceList" border> |
| | | <el-table-column label="åå
¬å¸" prop="formerCompany" min-width="180"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.formerCompany" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="30" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="åå
¬å¸é¨é¨" prop="formerDept" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.formerDept" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="åå
¬å¸èä½" prop="formerPosition" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.formerPosition" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å¼å§æ¥æ" prop="startDate" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-model="row.startDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ç»ææ¥æ" prop="endDate" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-model="row.endDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å·¥ä½æè¿°" prop="workDesc" min-width="220"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.workDesc" |
| | | type="textarea" |
| | | :rows="2" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="500" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="80" align="center"> |
| | | <template #default="scope"> |
| | | <el-button |
| | | v-if="form.staffWorkExperienceList.length > 1" |
| | | type="primary" |
| | | link |
| | | @click="removeWorkRow(scope.$index)" |
| | | > |
| | | å é¤ |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="table-add-row" @click="addWorkRow">æ°å»ºä¸è¡</div> |
| | | </el-card> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { toRefs } from "vue"; |
| | | |
| | | const props = defineProps({ |
| | | form: { type: Object, required: true }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:form"]); |
| | | |
| | | const { form } = toRefs(props); |
| | | |
| | | const addEducationRow = () => { |
| | | form.value.staffEducationList.push({ |
| | | education: "", |
| | | schoolName: "", |
| | | enrollTime: "", |
| | | graduateTime: "", |
| | | major: "", |
| | | degree: "", |
| | | }); |
| | | }; |
| | | |
| | | const removeEducationRow = (index) => { |
| | | if (form.value.staffEducationList.length <= 1) return; |
| | | form.value.staffEducationList.splice(index, 1); |
| | | }; |
| | | |
| | | const addWorkRow = () => { |
| | | form.value.staffWorkExperienceList.push({ |
| | | formerCompany: "", |
| | | formerDept: "", |
| | | formerPosition: "", |
| | | startDate: "", |
| | | endDate: "", |
| | | workDesc: "", |
| | | }); |
| | | }; |
| | | |
| | | const removeWorkRow = (index) => { |
| | | if (form.value.staffWorkExperienceList.length <= 1) return; |
| | | form.value.staffWorkExperienceList.splice(index, 1); |
| | | }; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-card { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .card-title-line { |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | } |
| | | |
| | | .table-add-row { |
| | | margin-top: 8px; |
| | | color: #409eff; |
| | | cursor: pointer; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div> |
| | | <!-- ç´§æ¥è系人 --> |
| | | <el-card class="form-card" shadow="never"> |
| | | <template #header> |
| | | <span class="card-title"> |
| | | <span class="card-title-line">|</span> |
| | | ç´§æ¥è系人 |
| | | </span> |
| | | </template> |
| | | <el-table :data="form.staffEmergencyContactList" border> |
| | | <el-table-column label="ç´§æ¥è系人å§å" prop="contactName" min-width="160"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.contactName" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="50" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ç´§æ¥è系人å
³ç³»" prop="contactRelation" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.contactRelation" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="20" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ç´§æ¥èç³»äººææº" prop="contactPhone" width="160"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.contactPhone" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="11" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ç´§æ¥è系人ä½å" prop="contactAddress" min-width="220"> |
| | | <template #default="{ row }"> |
| | | <el-input |
| | | v-model="row.contactAddress" |
| | | placeholder="请è¾å
¥" |
| | | clearable |
| | | maxlength="50" |
| | | show-word-limit |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="80" align="center"> |
| | | <template #default="scope"> |
| | | <el-button |
| | | v-if="form.staffEmergencyContactList.length > 1" |
| | | type="primary" |
| | | link |
| | | @click="removeEmergencyRow(scope.$index)" |
| | | > |
| | | å é¤ |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="table-add-row" @click="addEmergencyRow">æ°å»ºä¸è¡</div> |
| | | </el-card> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { toRefs } from "vue"; |
| | | |
| | | const props = defineProps({ |
| | | form: { type: Object, required: true } |
| | | }); |
| | | |
| | | const { form } = toRefs(props); |
| | | |
| | | const addEmergencyRow = () => { |
| | | form.value.staffEmergencyContactList.push({ |
| | | contactName: "", |
| | | contactRelation: "", |
| | | contactPhone: "", |
| | | contactAddress: "", |
| | | }); |
| | | }; |
| | | |
| | | const removeEmergencyRow = (index) => { |
| | | if (form.value.staffEmergencyContactList.length <= 1) return; |
| | | form.value.staffEmergencyContactList.splice(index, 1); |
| | | }; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-card { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .card-title-line { |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | } |
| | | |
| | | .table-add-row { |
| | | margin-top: 8px; |
| | | color: #409eff; |
| | | cursor: pointer; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-card class="form-card" shadow="never"> |
| | | <template #header> |
| | | <span class="card-title"> |
| | | <span class="card-title-line">|</span> |
| | | å¨èä¿¡æ¯ |
| | | </span> |
| | | </template> |
| | | |
| | | <!-- 第ä¸è¡ï¼ååå¼å§ / ååç»æ / è¯ç¨æ / è½¬æ£ --> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="6"> |
| | | <el-form-item label="å
¥èæ¥æ" prop="contractStartTime"> |
| | | <el-date-picker |
| | | v-model="form.contractStartTime" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | @change="calculateContractTerm" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="6"> |
| | | <el-form-item |
| | | label="ååç»ææ¥æ" |
| | | prop="contractEndTime" |
| | | required |
| | | :rules="[ |
| | | { |
| | | required: true, |
| | | message: 'è¯·éæ©ååç»ææ¥æ', |
| | | trigger: 'change', |
| | | }, |
| | | ]" |
| | | > |
| | | <el-date-picker |
| | | v-model="form.contractEndTime" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | @change="calculateContractTerm" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="6"> |
| | | <el-form-item label="è¯ç¨æï¼æï¼" prop="probationPeriod"> |
| | | <el-input-number |
| | | v-model="form.proTerm" |
| | | :min="0" |
| | | :max="24" |
| | | :precision="0" |
| | | :step="1" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="6"> |
| | | <el-form-item label="è½¬æ£æ¥æ" prop="positiveDate"> |
| | | <el-date-picker |
| | | v-model="form.positiveDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- 第äºè¡ï¼é¨é¨ / å²ä½ / åºæ¬å·¥èµ --> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="8"> |
| | | <el-form-item label="é¨é¨" prop="sysDeptId"> |
| | | <el-tree-select |
| | | v-model="form.sysDeptId" |
| | | :data="deptOptions" |
| | | check-strictly |
| | | :render-after-expand="false" |
| | | placeholder="è¯·éæ©" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="å²ä½" prop="sysPostId"> |
| | | <el-select |
| | | v-model="form.sysPostId" |
| | | placeholder="è¯·éæ©" |
| | | clearable |
| | | style="width: 100%" |
| | | > |
| | | <el-option |
| | | v-for="item in postOptions" |
| | | :key="item.postId" |
| | | :label="item.postName" |
| | | :value="item.postId" |
| | | :disabled="item.status === '1'" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="åºæ¬å·¥èµ" prop="basicSalary"> |
| | | <el-input-number |
| | | v-model="form.basicSalary" |
| | | :min="0" |
| | | :max="999999" |
| | | :precision="2" |
| | | :step="100" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-card> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { toRefs } from "vue"; |
| | | |
| | | const props = defineProps({ |
| | | form: { type: Object, required: true }, |
| | | postOptions: { type: Array, default: () => [] }, |
| | | deptOptions: { type: Array, default: () => [] }, |
| | | }); |
| | | |
| | | const { form, postOptions, deptOptions } = toRefs(props); |
| | | |
| | | // 计ç®ååå¹´é |
| | | const calculateContractTerm = () => { |
| | | if (form.value.contractStartTime && form.value.contractEndTime) { |
| | | const startDate = new Date(form.value.contractStartTime); |
| | | const endDate = new Date(form.value.contractEndTime); |
| | | |
| | | if (endDate > startDate) { |
| | | // 计ç®å¹´ä»½å·® |
| | | const yearDiff = endDate.getFullYear() - startDate.getFullYear(); |
| | | const monthDiff = endDate.getMonth() - startDate.getMonth(); |
| | | const dayDiff = endDate.getDate() - startDate.getDate(); |
| | | |
| | | let years = yearDiff; |
| | | |
| | | // å¦æç»ææ¥æçææ¥å°äºå¼å§æ¥æçææ¥ï¼ååå»1å¹´ |
| | | if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { |
| | | years = yearDiff - 1; |
| | | } |
| | | |
| | | form.value.contractTerm = Math.max(0, years); |
| | | } else { |
| | | form.value.contractTerm = 0; |
| | | } |
| | | } else { |
| | | form.value.contractTerm = 0; |
| | | } |
| | | }; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-card { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .card-title-line { |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | } |
| | | </style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <FormDialog |
| | | v-model="dialogFormVisible" |
| | | :operation-type="operationType" |
| | | :title="dialogTitle" |
| | | width="90%" |
| | | @close="closeDia" |
| | | @confirm="submitForm" |
| | | @cancel="closeDia" |
| | | > |
| | | <div class="form-dia-body"> |
| | | <el-form |
| | | ref="formRef" |
| | | :model="form" |
| | | :rules="rules" |
| | | label-position="top" |
| | | > |
| | | <BasicInfoSection |
| | | :form="form" |
| | | :operation-type="operationType" |
| | | :role-options="roleOptions" |
| | | /> |
| | | <JobInfoSection |
| | | :form="form" |
| | | :post-options="postOptions" |
| | | :dept-options="deptOptions" |
| | | /> |
| | | <EducationWorkSection :form="form" /> |
| | | <EmergencyAndAttachmentSection :form="form" /> |
| | | </el-form> |
| | | </div> |
| | | </FormDialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { |
| | | ref, |
| | | reactive, |
| | | toRefs, |
| | | onMounted, |
| | | getCurrentInstance, |
| | | nextTick, |
| | | } from "vue"; |
| | | import FormDialog from "@/components/Dialog/FormDialog.vue"; |
| | | import { findPostOptions } from "@/api/system/post.js"; |
| | | import { deptTreeSelect, getUser } from "@/api/system/user.js"; |
| | | import { |
| | | staffOnJobInfo, |
| | | createStaffOnJob, |
| | | updateStaffOnJob, |
| | | } from "@/api/personnelManagement/staffOnJob.js"; |
| | | |
| | | import BasicInfoSection from "./BasicInfoSection.vue"; |
| | | import JobInfoSection from "./JobInfoSection.vue"; |
| | | import EducationWorkSection from "./EducationWorkSection.vue"; |
| | | import EmergencyAndAttachmentSection from "./EmergencyAndAttachmentSection.vue"; |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | | const emit = defineEmits(["close"]); |
| | | |
| | | const dialogFormVisible = ref(false); |
| | | const operationType = ref("add"); |
| | | const id = ref(0); |
| | | const formRef = ref(null); |
| | | |
| | | const dialogTitle = () => |
| | | operationType.value === "add" ? "æ°å¢å
¥è" : "ç¼è¾äººå"; |
| | | |
| | | const createEmptyEducation = () => ({ |
| | | education: "", |
| | | schoolName: "", |
| | | enrollTime: "", |
| | | graduateTime: "", |
| | | major: "", |
| | | degree: "", |
| | | }); |
| | | |
| | | const createEmptyWork = () => ({ |
| | | formerCompany: "", |
| | | formerDept: "", |
| | | formerPosition: "", |
| | | startDate: "", |
| | | endDate: "", |
| | | workDesc: "", |
| | | }); |
| | | |
| | | const createEmptyEmergency = () => ({ |
| | | contactName: "", |
| | | contactRelation: "", |
| | | contactPhone: "", |
| | | contactAddress: "", |
| | | }); |
| | | |
| | | const createDefaultForm = () => ({ |
| | | id: undefined, |
| | | // åºæ¬ä¿¡æ¯ |
| | | staffNo: "", |
| | | staffName: "", |
| | | alias: "", |
| | | phone: "", |
| | | sex: "", |
| | | birthDate: "", |
| | | age: undefined, |
| | | nativePlace: "", |
| | | nation: "", |
| | | maritalStatus: "", |
| | | politicalStatus: "", |
| | | firstWorkDate: "", |
| | | workingYears: undefined, |
| | | idCardNo: "", |
| | | hukouType: "", |
| | | email: "", |
| | | currentAddress: "", |
| | | // å¨èä¿¡æ¯ |
| | | contractStartTime: "", |
| | | contractEndTime: "", |
| | | proTerm: undefined, |
| | | positiveDate: "", |
| | | sysDeptId: undefined, |
| | | sysPostId: undefined, |
| | | basicSalary: undefined, |
| | | // é¶è¡å¡ä¿¡æ¯ |
| | | bankName: "", |
| | | bankCardNo: "", |
| | | // æè²ç»å |
| | | staffEducationList: [createEmptyEducation()], |
| | | // å·¥ä½ç»å |
| | | staffWorkExperienceList: [createEmptyWork()], |
| | | // ç´§æ¥è系人 |
| | | staffEmergencyContactList: [createEmptyEmergency()], |
| | | // è§è²ï¼åéï¼ |
| | | roleId: undefined, |
| | | }); |
| | | |
| | | const state = reactive({ |
| | | form: createDefaultForm(), |
| | | rules: { |
| | | staffNo: [{ required: true, message: "请è¾å
¥åå·¥ç¼å·", trigger: "blur" }], |
| | | staffName: [{ required: true, message: "请è¾å
¥å§å", trigger: "blur" }], |
| | | phone: [{ required: true, message: "请è¾å
¥ææº", trigger: "blur" }], |
| | | sex: [{ required: true, message: "è¯·éæ©æ§å«", trigger: "change" }], |
| | | birthDate: [ |
| | | { required: true, message: "è¯·éæ©åºçæ¥æ", trigger: "change" }, |
| | | ], |
| | | contractStartTime: [ |
| | | { required: true, message: "è¯·éæ©å
¥èæ¥æ", trigger: "change" }, |
| | | ], |
| | | contractEndTime: [ |
| | | { required: true, message: "è¯·éæ©ååç»ææ¥æ", trigger: "change" }, |
| | | ], |
| | | sysDeptId: [ |
| | | { required: true, message: "è¯·éæ©é¨é¨", trigger: "change" }, |
| | | ], |
| | | roleId: [{ required: true, message: "è¯·éæ©è§è²", trigger: "change" }], |
| | | }, |
| | | postOptions: [], |
| | | deptOptions: [], |
| | | }); |
| | | |
| | | const { form, rules, postOptions, deptOptions } = toRefs(state); |
| | | const roleOptions = ref([]); |
| | | |
| | | const resetForm = () => { |
| | | Object.assign(form.value, createDefaultForm()); |
| | | nextTick(() => { |
| | | formRef.value?.clearValidate(); |
| | | }); |
| | | }; |
| | | |
| | | const fetchPostOptions = () => { |
| | | findPostOptions().then((res) => { |
| | | postOptions.value = res.data || []; |
| | | }); |
| | | }; |
| | | |
| | | const fetchDeptOptions = () => { |
| | | deptTreeSelect().then((response) => { |
| | | deptOptions.value = filterDisabledDept( |
| | | JSON.parse(JSON.stringify(response.data || [])) |
| | | ); |
| | | }); |
| | | }; |
| | | |
| | | const fetchRoleOptions = () => { |
| | | getUser().then((res) => { |
| | | roleOptions.value = res.roles || []; |
| | | }); |
| | | }; |
| | | |
| | | function filterDisabledDept(deptList) { |
| | | return deptList.filter((dept) => { |
| | | if (dept.disabled) { |
| | | return false; |
| | | } |
| | | if (dept.children && dept.children.length) { |
| | | dept.children = filterDisabledDept(dept.children); |
| | | } |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | const openDialog = (type, row) => { |
| | | operationType.value = type; |
| | | dialogFormVisible.value = true; |
| | | fetchPostOptions(); |
| | | fetchDeptOptions(); |
| | | fetchRoleOptions(); |
| | | resetForm(); |
| | | if (type === "edit" && row?.id) { |
| | | id.value = row.id; |
| | | staffOnJobInfo(id.value, {}).then((res) => { |
| | | const d = res.data || {}; |
| | | Object.assign(form.value, { |
| | | ...form.value, |
| | | ...d, |
| | | }); |
| | | if ( |
| | | !Array.isArray(form.value.staffEducationList) || |
| | | !form.value.staffEducationList.length |
| | | ) { |
| | | form.value.staffEducationList = [createEmptyEducation()]; |
| | | } |
| | | if ( |
| | | !Array.isArray(form.value.staffWorkExperienceList) || |
| | | !form.value.staffWorkExperienceList.length |
| | | ) { |
| | | form.value.staffWorkExperienceList = [createEmptyWork()]; |
| | | } |
| | | if ( |
| | | !Array.isArray(form.value.staffEmergencyContactList) || |
| | | !form.value.staffEmergencyContactList.length |
| | | ) { |
| | | form.value.staffEmergencyContactList = [createEmptyEmergency()]; |
| | | } |
| | | if (form.value.sysPostId === 0) { |
| | | form.value.sysPostId = undefined; |
| | | } |
| | | if (form.value.sysDeptId === 0) { |
| | | form.value.sysDeptId = undefined; |
| | | } |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | onMounted(() => { |
| | | fetchPostOptions(); |
| | | fetchDeptOptions(); |
| | | }); |
| | | |
| | | const submitForm = () => { |
| | | if (!form.value.sysPostId) { |
| | | form.value.sysPostId = undefined; |
| | | } |
| | | if (!form.value.sysDeptId) { |
| | | form.value.sysDeptId = undefined; |
| | | } |
| | | // å
¼å®¹å端å¯è½ä»ä½¿ç¨ roleIds æ°ç» |
| | | form.value.roleIds = form.value.roleId ? [form.value.roleId] : []; |
| | | formRef.value?.validate((valid) => { |
| | | if (valid) { |
| | | if (operationType.value === "add") { |
| | | createStaffOnJob(form.value).then(() => { |
| | | proxy.$modal.msgSuccess("æäº¤æå"); |
| | | closeDia(); |
| | | }); |
| | | } else { |
| | | updateStaffOnJob(id.value, form.value).then(() => { |
| | | proxy.$modal.msgSuccess("æäº¤æå"); |
| | | closeDia(); |
| | | }); |
| | | } |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | const closeDia = () => { |
| | | formRef.value?.resetFields(); |
| | | dialogFormVisible.value = false; |
| | | emit("close"); |
| | | }; |
| | | |
| | | defineExpose({ |
| | | openDialog, |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .form-dia-body { |
| | | padding: 0; |
| | | } |
| | | |
| | | .card-title-line { |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | } |
| | | |
| | | .form-card { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .dialog-footer { |
| | | text-align: right; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-dialog |
| | | v-model="isShow" |
| | | title="ç»ç¾åå" |
| | | width="800px" |
| | | @close="closeModal" |
| | | > |
| | | <el-form :model="form" label-width="140px" label-position="top" :rules="rules" ref="formRef"> |
| | | <el-form-item label="ååå¼å§æ¥æï¼" prop="contractStartTime"> |
| | | <el-date-picker |
| | | v-model="form.contractStartTime" |
| | | type="date" |
| | | placeholder="è¯·éæ©æ¥æ" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | clearable |
| | | style="width: 100%" |
| | | @change="calculateContractTerm" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="ååç»ææ¥æï¼" prop="contractEndTime"> |
| | | <el-date-picker |
| | | v-model="form.contractEndTime" |
| | | type="date" |
| | | placeholder="è¯·éæ©æ¥æ" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | clearable |
| | | style="width: 100%" |
| | | @change="calculateContractTerm" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="ååå¹´éï¼" prop="contractTerm"> |
| | | <el-input-number v-model="form.contractTerm" :precision="0" :step="1" style="width: 100%" :disabled="true"/> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">确认</el-button> |
| | | <el-button @click="closeModal">åæ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | // ç»ç¾åå |
| | | import { renewContract } from "@/api/personnelManagement/staffOnJob.js"; |
| | | import {computed, getCurrentInstance,} from "vue"; |
| | | |
| | | const emit = defineEmits(['update:visible', 'completed']); |
| | | |
| | | const data = reactive({ |
| | | form: { |
| | | contractTerm: 0, |
| | | contractStartTime: "", |
| | | contractEndTime: "", |
| | | }, |
| | | rules: { |
| | | contractTerm: [{ required: true, message: "请è¾å
¥", trigger: "blur" }], |
| | | contractStartTime: [{ required: true, message: "请è¾å
¥", trigger: "blur" }], |
| | | contractEndTime: [{ required: true, message: "请è¾å
¥", trigger: "blur" }], |
| | | } |
| | | }); |
| | | const { form, rules } = toRefs(data); |
| | | let { proxy } = getCurrentInstance() |
| | | |
| | | const props = defineProps({ |
| | | id: { |
| | | type: Number, |
| | | default: 0, |
| | | }, |
| | | |
| | | visible: { |
| | | type: Boolean, |
| | | required: true, |
| | | }, |
| | | }) |
| | | |
| | | const isShow = computed({ |
| | | get() { |
| | | return props.visible; |
| | | }, |
| | | set(val) { |
| | | emit('update:visible', val); |
| | | }, |
| | | }); |
| | | |
| | | // 计ç®ååå¹´é |
| | | const calculateContractTerm = () => { |
| | | if (form.value.contractStartTime && form.value.contractEndTime) { |
| | | const startDate = new Date(form.value.contractStartTime); |
| | | const endDate = new Date(form.value.contractEndTime); |
| | | |
| | | if (endDate > startDate) { |
| | | // 计ç®å¹´ä»½å·® |
| | | const yearDiff = endDate.getFullYear() - startDate.getFullYear(); |
| | | const monthDiff = endDate.getMonth() - startDate.getMonth(); |
| | | const dayDiff = endDate.getDate() - startDate.getDate(); |
| | | |
| | | let years = yearDiff; |
| | | |
| | | // å¦æç»ææ¥æçææ¥å°äºå¼å§æ¥æçææ¥ï¼ååå»1å¹´ |
| | | if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { |
| | | years = yearDiff - 1; |
| | | } |
| | | |
| | | form.value.contractTerm = Math.max(0, years); |
| | | } else { |
| | | form.value.contractTerm = 0; |
| | | } |
| | | } else { |
| | | form.value.contractTerm = 0; |
| | | } |
| | | }; |
| | | |
| | | const submitForm = () => { |
| | | proxy.$refs["formRef"].validate(valid => { |
| | | if (valid) { |
| | | renewContract(props.id, form.value).then(res => { |
| | | if (res.code === 200) { |
| | | proxy.$modal.msgSuccess("ç»ç¾ååæå"); |
| | | emit('completed'); |
| | | closeModal(); |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | // å
³éå¼¹æ¡ |
| | | const closeModal = () => { |
| | | // éç½®è¡¨åæ°æ® |
| | | form.value = { |
| | | contractTerm: 0, |
| | | contractStartTime: "", |
| | | contractEndTime: "", |
| | | }; |
| | | isShow.value = false; |
| | | }; |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div> |
| | | <el-dialog |
| | | v-model="dialogFormVisible" |
| | | title="详æ
" |
| | | width="70%" |
| | | @close="closeDia" |
| | | > |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :tableLoading="tableLoading" |
| | | height="600" |
| | | ></PIMTable> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button @click="closeDia">åæ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {ref} from "vue"; |
| | | import {staffOnJobInfo} from "@/api/personnelManagement/staffOnJob.js"; |
| | | const { proxy } = getCurrentInstance() |
| | | const emit = defineEmits(['close']) |
| | | |
| | | const dialogFormVisible = ref(false); |
| | | const operationType = ref('') |
| | | const tableColumn = ref([ |
| | | // { |
| | | // label: "ååå¹´é", |
| | | // prop: "contractTerm", |
| | | // }, |
| | | { |
| | | label: "ååå¼å§æ¥æ", |
| | | prop: "contractStartTime", |
| | | }, |
| | | { |
| | | label: "ååç»ææ¥æ", |
| | | prop: "contractEndTime", |
| | | }, |
| | | ]); |
| | | const tableData = ref([]); |
| | | const tableLoading = ref(false); |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openDialog = (type, row) => { |
| | | operationType.value = type; |
| | | dialogFormVisible.value = true; |
| | | if (operationType.value === 'edit') { |
| | | staffOnJobInfo({staffNo: row.staffNo}).then(res => { |
| | | tableData.value = res.data |
| | | }) |
| | | } |
| | | } |
| | | |
| | | // å
³éå¼¹æ¡ |
| | | const closeDia = () => { |
| | | dialogFormVisible.value = false; |
| | | emit('close') |
| | | }; |
| | | defineExpose({ |
| | | openDialog, |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å工档æ¡--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">å§åï¼</span> |
| | | <el-input |
| | | v-model="searchForm.staffName" |
| | | style="width: 240px" |
| | | placeholder="请è¾å
¥å§åæç´¢" |
| | | @change="handleQuery" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | /> |
| | | <span class="search_title search_title2">é¨é¨ï¼</span> |
| | | <el-tree-select |
| | | v-model="searchForm.sysDeptId" |
| | | :data="deptOptions" |
| | | check-strictly |
| | | :render-after-expand="false" |
| | | style="width: 240px" |
| | | placeholder="è¯·éæ©" |
| | | /> |
| | | <span class="search_title search_title2">å
¥èæ¥æï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.contractStartTime" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="è¯·éæ©" |
| | | /> |
| | | <!-- <span style="margin-left: 10px" class="search_title">ååç»ææ¥æï¼</span> --> |
| | | <!-- <el-date-picker v-model="searchForm.entryDate" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange" |
| | | placeholder="è¯·éæ©" clearable @change="changeDaterange" /> --> |
| | | <el-button type="primary" @click="handleQuery" style="margin-left: 10px" |
| | | >æç´¢</el-button |
| | | > |
| | | </div> |
| | | <div> |
| | | <el-button type="primary" @click="openFormNewOrEditFormDia('add')">æ°å¢å
¥è</el-button> |
| | | <el-button type="info" @click="handleImport">导å
¥</el-button> |
| | | <el-button @click="handleOut">导åº</el-button> |
| | | <!-- <el-button type="danger" plain @click="handleDelete">å é¤</el-button> --> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="true" |
| | | @selection-change="handleSelectionChange" |
| | | :tableLoading="tableLoading" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | ></PIMTable> |
| | | </div> |
| | | <show-form-dia ref="formDia" @close="handleQuery"></show-form-dia> |
| | | <new-or-edit-form-dia ref="formDiaNewOrEditFormDia" @close="handleQuery"></new-or-edit-form-dia> |
| | | <renew-contract |
| | | v-if="isShowRenewContractModal" |
| | | v-model:visible="isShowRenewContractModal" |
| | | :id="id" |
| | | @completed="handleQuery" |
| | | /> |
| | | |
| | | <!-- 导å
¥å¯¹è¯æ¡ --> |
| | | <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body> |
| | | <el-upload |
| | | ref="uploadRef" |
| | | :limit="1" |
| | | accept=".xlsx, .xls" |
| | | :headers="upload.headers" |
| | | :action="upload.url" |
| | | :disabled="upload.isUploading" |
| | | :on-progress="handleFileUploadProgress" |
| | | :on-success="handleFileSuccess" |
| | | :auto-upload="false" |
| | | drag |
| | | > |
| | | <el-icon class="el-icon--upload"><upload-filled /></el-icon> |
| | | <div class="el-upload__text">å°æä»¶æå°æ¤å¤ï¼æ<em>ç¹å»ä¸ä¼ </em></div> |
| | | <template #tip> |
| | | <div class="el-upload__tip text-center"> |
| | | <span>ä»
å
许导å
¥xlsãxlsxæ ¼å¼æä»¶ã</span> |
| | | <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline; margin-left: 5px;" @click="importTemplate">ä¸è½½æ¨¡æ¿</el-link> |
| | | </div> |
| | | </template> |
| | | </el-upload> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitFileForm">ç¡® å®</el-button> |
| | | <el-button @click="upload.open = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search, UploadFilled } from "@element-plus/icons-vue"; |
| | | import {onMounted, ref} from "vue"; |
| | | import {ElMessageBox} from "element-plus"; |
| | | import { deptTreeSelect } from "@/api/system/user.js"; |
| | | import {batchDeleteStaffOnJobs, staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js"; |
| | | import { getToken } from "@/utils/auth"; |
| | | import dayjs from "dayjs"; |
| | | |
| | | const NewOrEditFormDia = defineAsyncComponent(() => import("@/views/personnelManagement/employeeRecord/components/NewOrEditFormDia.vue")); |
| | | const ShowFormDia = defineAsyncComponent(() => import( "@/views/personnelManagement/employeeRecord/components/Show.vue")); |
| | | const RenewContract = defineAsyncComponent(() => import( "@/views/personnelManagement/employeeRecord/components/RenewContract.vue")); |
| | | |
| | | const data = reactive({ |
| | | searchForm: { |
| | | staffName: "", |
| | | entryDate: undefined, // å½å
¥æ¥æ |
| | | entryDateStart: undefined, |
| | | entryDateEnd: undefined, |
| | | }, |
| | | deptOptions: [], |
| | | }); |
| | | const { searchForm, deptOptions } = toRefs(data); |
| | | const isShowRenewContractModal = ref(false); |
| | | const id = ref(0); |
| | | const tableColumn = ref([ |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "staffState", |
| | | dataType: "tag", |
| | | formatData: (params) => { |
| | | if (params == 0) { |
| | | return "离è"; |
| | | } else if (params == 1) { |
| | | return "å¨è"; |
| | | } else { |
| | | return null; |
| | | } |
| | | }, |
| | | formatType: (params) => { |
| | | if (params == 0) { |
| | | return "danger"; |
| | | } else if (params == 1) { |
| | | return "primary"; |
| | | } else { |
| | | return null; |
| | | } |
| | | }, |
| | | }, |
| | | { |
| | | label: "åå·¥ç¼å·", |
| | | prop: "staffNo", |
| | | }, |
| | | { |
| | | label: "å§å", |
| | | prop: "staffName", |
| | | }, |
| | | { |
| | | label: "å«å", |
| | | prop: "alias", |
| | | }, |
| | | { |
| | | label: "ææº", |
| | | prop: "phone", |
| | | width: 150, |
| | | }, |
| | | { |
| | | label: "æ§å«", |
| | | prop: "sex", |
| | | }, |
| | | { |
| | | label: "åºçæ¥æ", |
| | | prop: "birthDate", |
| | | width: 120, |
| | | }, |
| | | { |
| | | label: "å
¥èæ¥æ", |
| | | prop: "contractStartTime", |
| | | width: 120, |
| | | }, |
| | | { |
| | | label: "å¹´é¾", |
| | | prop: "age", |
| | | }, |
| | | { |
| | | label: "ç±è´¯", |
| | | prop: "nativePlace", |
| | | }, |
| | | { |
| | | label: "æ°æ", |
| | | prop: "nation", |
| | | width: 100, |
| | | }, |
| | | { |
| | | label: "å©å§»ç¶åµ", |
| | | prop: "maritalStatus", |
| | | width: 100, |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: 'right', |
| | | width: 180, |
| | | operation: [ |
| | | { |
| | | name: "ç¼è¾", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | openFormNewOrEditFormDia("edit", row); |
| | | }, |
| | | }, |
| | | { |
| | | name: "ç»ç¾åå", |
| | | type: "text", |
| | | showHide: row => row.staffState === 1, |
| | | clickFun: (row) => { |
| | | isShowRenewContractModal.value = true; |
| | | id.value = row.id; |
| | | }, |
| | | }, |
| | | // { |
| | | // name: "详æ
", |
| | | // type: "text", |
| | | // clickFun: (row) => { |
| | | // openForm("edit", row); |
| | | // }, |
| | | // }, |
| | | ], |
| | | }, |
| | | ]); |
| | | const tableData = ref([]); |
| | | const selectedRows = ref([]); |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 100, |
| | | total: 0 |
| | | }); |
| | | const formDia = ref() |
| | | const formDiaNewOrEditFormDia = ref() |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | // 导å
¥ç¸å
³ |
| | | const uploadRef = ref(null) |
| | | const upload = reactive({ |
| | | // æ¯å¦æ¾ç¤ºå¼¹åºå± |
| | | open: false, |
| | | // å¼¹åºå±æ é¢ |
| | | title: "", |
| | | // æ¯å¦ç¦ç¨ä¸ä¼ |
| | | isUploading: false, |
| | | // 设置ä¸ä¼ ç请æ±å¤´é¨ |
| | | headers: { Authorization: "Bearer " + getToken() }, |
| | | // ä¸ä¼ çå°å |
| | | url: import.meta.env.VITE_APP_BASE_API + "/staff/staffOnJob/import" |
| | | }) |
| | | |
| | | const fetchDeptOptions = () => { |
| | | deptTreeSelect().then(response => { |
| | | console.log(response.data) |
| | | deptOptions.value = filterDisabledDept( |
| | | JSON.parse(JSON.stringify(response.data)) |
| | | ); |
| | | }); |
| | | }; |
| | | const filterDisabledDept = deptList => { |
| | | return deptList.filter(dept => { |
| | | if (dept.disabled) { |
| | | return false; |
| | | } |
| | | if (dept.children && dept.children.length) { |
| | | dept.children = filterDisabledDept(dept.children); |
| | | } |
| | | return true; |
| | | }); |
| | | }; |
| | | const changeDaterange = (value) => { |
| | | searchForm.value.entryDateStart = undefined; |
| | | searchForm.value.entryDateEnd = undefined; |
| | | if (value) { |
| | | searchForm.value.entryDateStart = dayjs(value[0]).format("YYYY-MM-DD"); |
| | | searchForm.value.entryDateEnd = dayjs(value[1]).format("YYYY-MM-DD"); |
| | | } |
| | | getList(); |
| | | }; |
| | | // æ¥è¯¢å表 |
| | | /** æç´¢æé®æä½ */ |
| | | const handleQuery = () => { |
| | | page.current = 1; |
| | | getList(); |
| | | }; |
| | | const pagination = (obj) => { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | getList(); |
| | | }; |
| | | const getList = () => { |
| | | fetchDeptOptions(); |
| | | tableLoading.value = true; |
| | | const params = { ...searchForm.value, ...page }; |
| | | params.entryDate = undefined |
| | | staffOnJobListPage({...params}).then(res => { |
| | | tableLoading.value = false; |
| | | tableData.value = res.data.records |
| | | page.total = res.data.total; |
| | | }).catch(err => { |
| | | tableLoading.value = false; |
| | | }) |
| | | }; |
| | | // è¡¨æ ¼éæ©æ°æ® |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.value = selection; |
| | | }; |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openForm = (type, row) => { |
| | | nextTick(() => { |
| | | formDia.value?.openDialog(type, row) |
| | | }) |
| | | }; |
| | | const openFormNewOrEditFormDia = (type, row) => { |
| | | nextTick(() => { |
| | | formDiaNewOrEditFormDia.value?.openDialog(type, row) |
| | | }) |
| | | }; |
| | | |
| | | // å é¤ |
| | | const handleDelete = () => { |
| | | let ids = []; |
| | | if (selectedRows.value.length > 0) { |
| | | ids = selectedRows.value.map((item) => item.id); |
| | | } else { |
| | | proxy.$modal.msgWarning("è¯·éæ©æ°æ®"); |
| | | return; |
| | | } |
| | | ElMessageBox.confirm("éä¸çå
容å°è¢«å é¤ï¼æ¯å¦ç¡®è®¤å é¤ï¼", "导åº", { |
| | | confirmButtonText: "确认", |
| | | cancelButtonText: "åæ¶", |
| | | type: "warning", |
| | | }) |
| | | .then(() => { |
| | | batchDeleteStaffOnJobs(ids).then((res) => { |
| | | proxy.$modal.msgSuccess("å 餿å"); |
| | | getList(); |
| | | }); |
| | | }) |
| | | .catch(() => { |
| | | proxy.$modal.msg("已忶"); |
| | | }); |
| | | }; |
| | | |
| | | // å¯¼åº |
| | | const handleOut = () => { |
| | | ElMessageBox.confirm("éä¸çå
容å°è¢«å¯¼åºï¼æ¯å¦ç¡®è®¤å¯¼åºï¼", "导åº", { |
| | | confirmButtonText: "确认", |
| | | cancelButtonText: "åæ¶", |
| | | type: "warning", |
| | | }) |
| | | .then(() => { |
| | | proxy.download("/staff/staffOnJob/export", {staffState: 1}, "åå·¥å°è´¦.xlsx"); |
| | | }) |
| | | .catch(() => { |
| | | proxy.$modal.msg("已忶"); |
| | | }); |
| | | }; |
| | | |
| | | // 导å
¥æé®æä½ |
| | | const handleImport = () => { |
| | | upload.title = "å工导å
¥" |
| | | upload.open = true |
| | | } |
| | | |
| | | // ä¸è½½æ¨¡æ¿æä½ |
| | | const importTemplate = () => { |
| | | proxy.download("/staff/staffOnJob/downloadTemplate", {}, `å工导å
¥æ¨¡æ¿_${new Date().getTime()}.xlsx`) |
| | | } |
| | | |
| | | // æä»¶ä¸ä¼ ä¸å¤ç |
| | | const handleFileUploadProgress = (event, file, fileList) => { |
| | | upload.isUploading = true |
| | | } |
| | | |
| | | // æä»¶ä¸ä¼ æåå¤ç |
| | | const handleFileSuccess = (response, file, fileList) => { |
| | | upload.open = false |
| | | upload.isUploading = false |
| | | proxy.$refs["uploadRef"].handleRemove(file) |
| | | proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导å
¥ç»æ", { dangerouslyUseHTMLString: true }) |
| | | getList() |
| | | } |
| | | |
| | | // æäº¤ä¸ä¼ æä»¶ |
| | | const submitFileForm = () => { |
| | | proxy.$refs["uploadRef"].submit() |
| | | } |
| | | |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .search_title2 { |
| | | margin-left: 10px; |
| | | } |
| | | </style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div> |
| | | <el-dialog |
| | | v-model="dialogFormVisible" |
| | | title="详æ
" |
| | | width="70%" |
| | | @close="closeDia" |
| | | > |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :tableLoading="tableLoading" |
| | | height="600" |
| | | ></PIMTable> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button @click="closeDia">åæ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | <Files ref="filesDia"></Files> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {ref} from "vue"; |
| | | import {findStaffContractListPage} from "@/api/personnelManagement/staffContract.js"; |
| | | const Files = defineAsyncComponent(() => import( "@/views/personnelManagement/contractManagement/filesDia.vue")); |
| | | const { proxy } = getCurrentInstance() |
| | | const emit = defineEmits(['close']) |
| | | const filesDia = ref() |
| | | const dialogFormVisible = ref(false); |
| | | const operationType = ref('') |
| | | const tableColumn = ref([ |
| | | { |
| | | label: "ååå¹´é", |
| | | prop: "contractTerm", |
| | | }, |
| | | { |
| | | label: "ååå¼å§æ¥æ", |
| | | prop: "contractStartTime", |
| | | }, |
| | | { |
| | | label: "ååç»ææ¥æ", |
| | | prop: "contractEndTime", |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: 'right', |
| | | width: 120, |
| | | operation: [ |
| | | { |
| | | name: "ä¸ä¼ éä»¶", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | filesDia.value.openDialog( row,'åå') |
| | | }, |
| | | } |
| | | ], |
| | | }, |
| | | ]); |
| | | const tableData = ref([]); |
| | | const tableLoading = ref(false); |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openDialog = (type, row) => { |
| | | operationType.value = type; |
| | | dialogFormVisible.value = true; |
| | | if (operationType.value === 'edit') { |
| | | findStaffContractListPage({staffOnJobId: row.id}).then(res => { |
| | | tableData.value = res.data.records |
| | | }) |
| | | } |
| | | } |
| | | |
| | | const openUploadFile = (row) => { |
| | | filesDia.value.open = true |
| | | filesDia.value.row = row |
| | | } |
| | | |
| | | // å
³éå¼¹æ¡ |
| | | const closeDia = () => { |
| | | dialogFormVisible.value = false; |
| | | emit('close') |
| | | }; |
| | | defineExpose({ |
| | | openDialog, |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div> |
| | | <el-dialog |
| | | v-model="dialogFormVisible" |
| | | title="ä¸ä¼ éä»¶" |
| | | width="50%" |
| | | @close="closeDia" |
| | | > |
| | | <div style="margin-bottom: 10px;text-align: right"> |
| | | <el-upload |
| | | v-model:file-list="fileList" |
| | | class="upload-demo" |
| | | :action="uploadUrl" |
| | | :on-success="handleUploadSuccess" |
| | | :on-error="handleUploadError" |
| | | name="file" |
| | | :show-file-list="false" |
| | | :headers="headers" |
| | | style="display: inline;margin-right: 10px" |
| | | > |
| | | <el-button type="primary">ä¸ä¼ éä»¶</el-button> |
| | | </el-upload> |
| | | <el-button type="danger" plain @click="handleDelete">å é¤</el-button> |
| | | </div> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :tableLoading="tableLoading" |
| | | :isSelection="true" |
| | | :page="page" |
| | | @selection-change="handleSelectionChange" |
| | | height="500" |
| | | @pagination="paginationSearch" |
| | | :total="page.total" |
| | | > |
| | | </PIMTable> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button @click="closeDia">åæ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | <filePreview ref="filePreviewRef" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {ref} from "vue"; |
| | | import {ElMessageBox} from "element-plus"; |
| | | import {getToken} from "@/utils/auth.js"; |
| | | import filePreview from '@/components/filePreview/index.vue' |
| | | import { |
| | | fileAdd, |
| | | fileDel, |
| | | fileListPage |
| | | } from "@/api/financialManagement/revenueManagement.js"; |
| | | import Pagination from "@/components/PIMTable/Pagination.vue"; |
| | | const { proxy } = getCurrentInstance() |
| | | const emit = defineEmits(['close']) |
| | | |
| | | const dialogFormVisible = ref(false); |
| | | const currentId = ref('') |
| | | const selectedRows = ref([]); |
| | | const filePreviewRef = ref() |
| | | const tableColumn = ref([ |
| | | { |
| | | label: "æä»¶åç§°", |
| | | prop: "name", |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | operation: [ |
| | | { |
| | | name: "ä¸è½½", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | downLoadFile(row); |
| | | }, |
| | | }, |
| | | { |
| | | name: "é¢è§", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | lookFile(row); |
| | | }, |
| | | } |
| | | ], |
| | | }, |
| | | ]); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 100, |
| | | }); |
| | | const total = ref(0); |
| | | const tableData = ref([]); |
| | | const fileList = ref([]); |
| | | const tableLoading = ref(false); |
| | | const accountType = ref('') |
| | | const headers = ref({ |
| | | Authorization: "Bearer " + getToken(), |
| | | }); |
| | | const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/file/upload"); // ä¸ä¼ çå¾çæå¡å¨å°å |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openDialog = (row,type) => { |
| | | accountType.value = type; |
| | | dialogFormVisible.value = true; |
| | | currentId.value = row.id; |
| | | getList() |
| | | } |
| | | const paginationSearch = (obj) => { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | getList(); |
| | | }; |
| | | const getList = () => { |
| | | fileListPage({accountId: currentId.value,accountType:accountType.value, ...page}).then(res => { |
| | | tableData.value = res.data.records; |
| | | page.total = res.data.total; |
| | | }) |
| | | } |
| | | // è¡¨æ ¼éæ©æ°æ® |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.value = selection; |
| | | }; |
| | | |
| | | // å
³éå¼¹æ¡ |
| | | const closeDia = () => { |
| | | dialogFormVisible.value = false; |
| | | emit('close') |
| | | }; |
| | | // ä¸ä¼ æåå¤ç |
| | | function handleUploadSuccess(res, file) { |
| | | // 妿ä¸ä¼ æå |
| | | if (res.code == 200) { |
| | | const fileRow = {} |
| | | fileRow.name = res.data.originalName |
| | | fileRow.url = res.data.tempPath |
| | | uploadFile(fileRow) |
| | | } else { |
| | | proxy.$modal.msgError("æä»¶ä¸ä¼ 失败"); |
| | | } |
| | | } |
| | | function uploadFile(file) { |
| | | file.accountId = currentId.value; |
| | | file.accountType = accountType.value; |
| | | fileAdd(file).then(res => { |
| | | proxy.$modal.msgSuccess("æä»¶ä¸ä¼ æå"); |
| | | getList() |
| | | }) |
| | | } |
| | | // ä¸ä¼ 失败å¤ç |
| | | function handleUploadError() { |
| | | proxy.$modal.msgError("æä»¶ä¸ä¼ 失败"); |
| | | } |
| | | // ä¸è½½éä»¶ |
| | | const downLoadFile = (row) => { |
| | | proxy.$download.byUrl(row.url, row.originalFilename); |
| | | } |
| | | // å é¤ |
| | | const handleDelete = () => { |
| | | let ids = []; |
| | | if (selectedRows.value.length > 0) { |
| | | ids = selectedRows.value.map((item) => item.id); |
| | | } else { |
| | | proxy.$modal.msgWarning("è¯·éæ©æ°æ®"); |
| | | return; |
| | | } |
| | | ElMessageBox.confirm("éä¸çå
容å°è¢«å é¤ï¼æ¯å¦ç¡®è®¤å é¤ï¼", "导åº", { |
| | | confirmButtonText: "确认", |
| | | cancelButtonText: "åæ¶", |
| | | type: "warning", |
| | | }).then(() => { |
| | | fileDel(ids).then((res) => { |
| | | proxy.$modal.msgSuccess("å 餿å"); |
| | | getList(); |
| | | }); |
| | | }).catch(() => { |
| | | proxy.$modal.msg("已忶"); |
| | | }); |
| | | }; |
| | | // é¢è§éä»¶ |
| | | const lookFile = (row) => { |
| | | filePreviewRef.value.open(row.url) |
| | | } |
| | | |
| | | defineExpose({ |
| | | openDialog, |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼åå·¥åå--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">å§åï¼</span> |
| | | <el-input v-model="searchForm.staffName" style="width: 240px" placeholder="请è¾å
¥å§åæç´¢" @change="handleQuery" |
| | | clearable :prefix-icon="Search" /> |
| | | <span style="margin-left: 10px" class="search_title">ååç»ææ¥æï¼</span> |
| | | <el-date-picker v-model="searchForm.entryDate" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange" |
| | | placeholder="è¯·éæ©" clearable @change="changeDaterange" /> |
| | | <el-button type="primary" @click="handleQuery" style="margin-left: 10px">æç´¢</el-button> |
| | | </div> |
| | | <div> |
| | | <el-button @click="handleOut">导åº</el-button> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable rowKey="id" :column="tableColumn" :tableData="tableData" :page="page" :isSelection="true" |
| | | @selection-change="handleSelectionChange" :tableLoading="tableLoading" @pagination="pagination" |
| | | :total="page.total"></PIMTable> |
| | | </div> |
| | | <form-dia ref="formDia" @close="handleQuery"></form-dia> |
| | | |
| | | <!-- åå导å
¥å¯¹è¯æ¡ --> |
| | | <el-dialog |
| | | :title="upload.title" |
| | | v-model="upload.open" |
| | | width="400px" |
| | | append-to-body |
| | | > |
| | | <el-upload |
| | | ref="uploadRef" |
| | | :limit="1" |
| | | accept=".xlsx, .xls" |
| | | :headers="upload.headers" |
| | | :action="upload.url + '?updateSupport=' + upload.updateSupport" |
| | | :disabled="upload.isUploading" |
| | | :on-progress="handleFileUploadProgress" |
| | | :on-success="handleFileSuccess" |
| | | :auto-upload="false" |
| | | drag |
| | | > |
| | | <el-icon class="el-icon--upload"><upload-filled /></el-icon> |
| | | <div class="el-upload__text">å°æä»¶æå°æ¤å¤ï¼æ<em>ç¹å»ä¸ä¼ </em></div> |
| | | <template #tip> |
| | | <div class="el-upload__tip text-center"> |
| | | <span>ä»
å
许导å
¥xlsãxlsxæ ¼å¼æä»¶ã</span> |
| | | <!-- <el-link |
| | | type="primary" |
| | | :underline="false" |
| | | style="font-size: 12px; vertical-align: baseline" |
| | | @click="importTemplate" |
| | | >ä¸è½½æ¨¡æ¿</el-link |
| | | > --> |
| | | </div> |
| | | </template> |
| | | </el-upload> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitFileForm">ç¡® å®</el-button> |
| | | <el-button @click="upload.open = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | <files-dia ref="filesDia"></files-dia> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import { onMounted, ref } from "vue"; |
| | | import FormDia from "@/views/personnelManagement/contractManagement/components/formDia.vue"; |
| | | import { ElMessageBox } from "element-plus"; |
| | | import { staffOnJobListPage } from "@/api/personnelManagement/staffOnJob.js"; |
| | | import dayjs from "dayjs"; |
| | | import { getToken } from "@/utils/auth.js"; |
| | | import FilesDia from "./filesDia.vue"; |
| | | const data = reactive({ |
| | | searchForm: { |
| | | staffName: "", |
| | | entryDate: null, // å½å
¥æ¥æ |
| | | entryDateStart: undefined, |
| | | entryDateEnd: undefined, |
| | | }, |
| | | }); |
| | | const { searchForm } = toRefs(data); |
| | | const tableColumn = ref([ |
| | | { |
| | | label: "ç¶æ", |
| | | prop: "staffState", |
| | | dataType: "tag", |
| | | formatData: (params) => { |
| | | if (params == 0) { |
| | | return "离è"; |
| | | } else if (params == 1) { |
| | | return "å¨è"; |
| | | } else { |
| | | return null; |
| | | } |
| | | }, |
| | | formatType: (params) => { |
| | | if (params == 0) { |
| | | return "danger"; |
| | | } else if (params == 1) { |
| | | return "primary"; |
| | | } else { |
| | | return null; |
| | | } |
| | | }, |
| | | }, |
| | | { |
| | | label: "åå·¥ç¼å·", |
| | | prop: "staffNo", |
| | | }, |
| | | { |
| | | label: "å§å", |
| | | prop: "staffName", |
| | | }, |
| | | { |
| | | label: "æ§å«", |
| | | prop: "sex", |
| | | }, |
| | | { |
| | | label: "æ·ç±ä½å", |
| | | prop: "nativePlace", |
| | | }, |
| | | { |
| | | label: "å²ä½", |
| | | prop: "postJob", |
| | | }, |
| | | { |
| | | label: "ç°ä½å", |
| | | prop: "adress", |
| | | width: 200 |
| | | }, |
| | | { |
| | | label: "第ä¸å¦å", |
| | | prop: "firstStudy", |
| | | }, |
| | | { |
| | | label: "ä¸ä¸", |
| | | prop: "profession", |
| | | width: 100 |
| | | }, |
| | | { |
| | | label: "å¹´é¾", |
| | | prop: "age", |
| | | }, |
| | | { |
| | | label: "èç³»çµè¯", |
| | | prop: "phone", |
| | | width: 150 |
| | | }, |
| | | { |
| | | label: "ç´§æ¥è系人", |
| | | prop: "emergencyContact", |
| | | width: 120 |
| | | }, |
| | | { |
| | | label: "ç´§æ¥è系人çµè¯", |
| | | prop: "emergencyContactPhone", |
| | | width: 150 |
| | | }, |
| | | // { |
| | | // label: "ååå¹´é", |
| | | // prop: "contractTerm", |
| | | // }, |
| | | // { |
| | | // label: "ååå¼å§æ¥æ", |
| | | // prop: "contractStartTime", |
| | | // width: 120 |
| | | // }, |
| | | { |
| | | label: "ååç»ææ¥æ", |
| | | prop: "contractExpireTime", |
| | | width: 120 |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: 'right', |
| | | width: 120, |
| | | operation: [ |
| | | { |
| | | name: "详æ
", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | openForm("edit", row); |
| | | }, |
| | | } |
| | | ], |
| | | }, |
| | | ]); |
| | | const filesDia = ref() |
| | | const tableData = ref([]); |
| | | const selectedRows = ref([]); |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 100, |
| | | total: 0, |
| | | }); |
| | | const formDia = ref() |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | const changeDaterange = (value) => { |
| | | searchForm.value.entryDateStart = undefined; |
| | | searchForm.value.entryDateEnd = undefined; |
| | | if (value) { |
| | | searchForm.value.entryDateStart = dayjs(value[0]).format("YYYY-MM-DD"); |
| | | searchForm.value.entryDateEnd = dayjs(value[1]).format("YYYY-MM-DD"); |
| | | } |
| | | getList(); |
| | | }; |
| | | // æå¼éä»¶å¼¹æ¡ |
| | | const openFilesFormDia = (row) => { |
| | | console.log(row) |
| | | nextTick(() => { |
| | | filesDia.value?.openDialog( row,'åå') |
| | | }) |
| | | }; |
| | | // æ¥è¯¢å表 |
| | | /** æç´¢æé®æä½ */ |
| | | const handleQuery = () => { |
| | | page.current = 1; |
| | | getList(); |
| | | }; |
| | | const pagination = (obj) => { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | getList(); |
| | | }; |
| | | const getList = () => { |
| | | tableLoading.value = true; |
| | | const params = { ...searchForm.value, ...page }; |
| | | params.entryDate = undefined |
| | | params.staffState = 1 |
| | | staffOnJobListPage(params).then(res => { |
| | | tableLoading.value = false; |
| | | tableData.value = res.data.records |
| | | page.total = res.data.total; |
| | | }).catch(err => { |
| | | tableLoading.value = false; |
| | | }) |
| | | }; |
| | | // è¡¨æ ¼éæ©æ°æ® |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.value = selection; |
| | | }; |
| | | |
| | | // æå¼å¼¹æ¡ |
| | | const openForm = (type, row) => { |
| | | nextTick(() => { |
| | | formDia.value?.openDialog(type, row) |
| | | }) |
| | | }; |
| | | // å¯¼åº |
| | | const handleOut = () => { |
| | | ElMessageBox.confirm("éä¸çå
容å°è¢«å¯¼åºï¼æ¯å¦ç¡®è®¤å¯¼åºï¼", "导åº", { |
| | | confirmButtonText: "确认", |
| | | cancelButtonText: "åæ¶", |
| | | type: "warning", |
| | | }) |
| | | .then(() => { |
| | | proxy.download("/staff/staffOnJob/export", {staffState: 1}, "åå管ç.xlsx"); |
| | | }) |
| | | .catch(() => { |
| | | proxy.$modal.msg("已忶"); |
| | | }); |
| | | }; |
| | | const upload = reactive({ |
| | | // æ¯å¦æ¾ç¤ºå¼¹åºå±ï¼åå导å
¥ï¼ |
| | | open: false, |
| | | // å¼¹åºå±æ é¢ï¼åå导å
¥ï¼ |
| | | title: "", |
| | | // æ¯å¦ç¦ç¨ä¸ä¼ |
| | | isUploading: false, |
| | | // æ¯å¦æ´æ°å·²ç»åå¨çç¨æ·æ°æ® |
| | | updateSupport: 1, |
| | | // 设置ä¸ä¼ ç请æ±å¤´é¨ |
| | | headers: { Authorization: "Bearer " + getToken() }, |
| | | // ä¸ä¼ çå°å |
| | | url: import.meta.env.VITE_APP_BASE_API + "/staff/staffOnJob/import", |
| | | }); |
| | | /** 导å
¥æé®æä½ */ |
| | | function handleImport() { |
| | | upload.title = "åå导å
¥"; |
| | | upload.open = true; |
| | | } |
| | | /** æäº¤ä¸ä¼ æä»¶ */ |
| | | function submitFileForm() { |
| | | console.log(upload.url + '?updateSupport=' + upload.updateSupport) |
| | | proxy.$refs["uploadRef"].submit(); |
| | | } |
| | | /**æä»¶ä¸ä¼ ä¸å¤ç */ |
| | | const handleFileUploadProgress = (event, file, fileList) => { |
| | | upload.isUploading = true; |
| | | }; |
| | | /** æä»¶ä¸ä¼ æåå¤ç */ |
| | | const handleFileSuccess = (response, file, fileList) => { |
| | | upload.open = false; |
| | | upload.isUploading = false; |
| | | proxy.$refs["uploadRef"].handleRemove(file); |
| | | getList(); |
| | | }; |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped></style> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼è°å²ç³è¯·--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">ç³è¯·äººï¼</span> |
| | | <el-select |
| | | v-model="searchForm.applicantId" |
| | | filterable |
| | | remote |
| | | clearable |
| | | reserve-keyword |
| | | placeholder="è¯·éæ©ææç´¢ç³è¯·äºº" |
| | | style="width: 220px" |
| | | :remote-method="remoteSearchApplicant" |
| | | :loading="applicantSearchLoading" |
| | | > |
| | | <el-option |
| | | v-for="u in applicantSearchOptions" |
| | | :key="u.userId" |
| | | :label="userSelectLabel(u)" |
| | | :value="u.userId" |
| | | /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">è½¬å²æ¶é´ï¼</span> |
| | | <el-date-picker |
| | | v-model="searchForm.transferDateRange" |
| | | 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" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div> |
| | | <el-button type="primary" @click="openFormDialog('add')">æ°å¢è°å²ç³è¯·</el-button> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="720px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="transfer-apply-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="transfer-apply-form"> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <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="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è½¬å²æ¥æ" prop="transferDate"> |
| | | <el-date-picker |
| | | v-model="form.transferDate" |
| | | 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="åå²ä½" prop="originalPostName"> |
| | | <el-input v-model="form.originalPostName" placeholder="éæ©ç³è¯·äººåèªå¨å¸¦åº" disabled /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="转å
¥å²ä½" prop="targetPostId"> |
| | | <el-select v-model="form.targetPostId" placeholder="è¯·éæ©è½¬å
¥å²ä½" clearable filterable style="width: 100%"> |
| | | <el-option |
| | | v-for="p in targetPostOptions" |
| | | :key="p.postId" |
| | | :label="p.postName" |
| | | :value="p.postId" |
| | | :disabled="p.status === '1' || p.status === 1" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å®¡æ¹æ¹å¼" prop="approvalMode"> |
| | | <el-radio-group v-model="form.approvalMode"> |
| | | <el-radio value="parallel">ä¸ç¾</el-radio> |
| | | <el-radio value="countersign">ä¼ç¾</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="审æ¹äºº" prop="approverIds"> |
| | | <el-tree-select |
| | | v-model="form.approverIds" |
| | | :data="approverTreeData" |
| | | multiple |
| | | collapse-tags |
| | | collapse-tags-tooltip |
| | | :max-collapse-tags="2" |
| | | :render-after-expand="false" |
| | | placeholder="è¯·éæ©å®¡æ¹äººï¼å¯å¤éï¼" |
| | | style="width: 100%" |
| | | :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }" |
| | | check-strictly |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="è°å²ç³è¯·è¯¦æ
" width="560px" append-to-body> |
| | | <el-descriptions :column="1" border> |
| | | <el-descriptions-item label="ç³è¯·äºº">{{ detailRow.applicantName }}</el-descriptions-item> |
| | | <el-descriptions-item label="è½¬å²æ¥æ">{{ detailRow.transferDate }}</el-descriptions-item> |
| | | <el-descriptions-item label="åå²ä½">{{ detailRow.originalPostName }}</el-descriptions-item> |
| | | <el-descriptions-item label="转å
¥å²ä½">{{ detailRow.targetPostName }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç»æ">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ¹æ¹å¼">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹äºº">{{ detailRow.approverNames || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { findPostOptions } from "@/api/system/post.js"; |
| | | import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | /** ä¸å端约å®åæ®µï¼æ¬å°å ä½ï¼åææ¥å£å¯¹é½ï¼ */ |
| | | const createEmptyForm = () => ({ |
| | | id: undefined, |
| | | applicantId: "", |
| | | applicantName: "", |
| | | transferDate: "", |
| | | originalPostId: "", |
| | | originalPostName: "", |
| | | targetPostId: "", |
| | | targetPostName: "", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | }); |
| | | |
| | | /** ç³»ç»ç¨æ·ç¼åï¼/system/user/userListNoPageByTenantIdï¼ä¸è½¬æ£ç³è¯·çä¸è´ï¼ */ |
| | | const allUsersCache = ref([]); |
| | | /** å²ä½åå
¸ postId -> postNameï¼/system/post/optionselectï¼ä¸å工档æ¡å
¥è表åä¸è´ï¼ */ |
| | | const postIdToName = ref({}); |
| | | const targetPostOptions = ref([]); |
| | | |
| | | function rebuildPostIdMap() { |
| | | const m = {}; |
| | | for (const p of targetPostOptions.value || []) { |
| | | const id = p.postId ?? p.value ?? p.id; |
| | | if (id != null && id !== "") m[String(id)] = p.postName ?? p.label ?? ""; |
| | | } |
| | | postIdToName.value = m; |
| | | } |
| | | |
| | | function targetPostNameById(postId) { |
| | | if (postId == null || postId === "") return ""; |
| | | const k = String(postId); |
| | | return ( |
| | | postIdToName.value[k] || |
| | | targetPostOptions.value.find((x) => String(x.postId ?? x.id ?? x.value) === k)?.postName || |
| | | "" |
| | | ); |
| | | } |
| | | |
| | | 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 firstPostId(user) { |
| | | if (!user) return undefined; |
| | | if (Array.isArray(user.postIds) && user.postIds.length) return user.postIds[0]; |
| | | if (user.postId != null && user.postId !== "") return user.postId; |
| | | return undefined; |
| | | } |
| | | |
| | | /** ä»ç¨æ·å¯¹è±¡è§£æãåå²ä½ãï¼å
¼å®¹ postName / postIds / posts ç常è§è¿åï¼ */ |
| | | function resolveOriginalPost(user) { |
| | | if (!user) return { originalPostId: "", originalPostName: "" }; |
| | | const nameStr = (user.postName ?? user.postname ?? "").toString().trim(); |
| | | if (nameStr) { |
| | | const pid = firstPostId(user); |
| | | return { originalPostId: pid != null && pid !== "" ? String(pid) : "", originalPostName: nameStr }; |
| | | } |
| | | if (Array.isArray(user.posts) && user.posts.length) { |
| | | const p0 = user.posts[0]; |
| | | return { |
| | | originalPostId: p0.postId != null ? String(p0.postId) : "", |
| | | originalPostName: (p0.postName ?? "").toString() || "æªå½åå²ä½", |
| | | }; |
| | | } |
| | | const pid = firstPostId(user); |
| | | if (pid != null && pid !== "") { |
| | | const n = postIdToName.value[String(pid)] || ""; |
| | | return { |
| | | originalPostId: String(pid), |
| | | originalPostName: n || "å½åå²ä½ï¼æªå¨å²ä½åå
¸ä¸ï¼", |
| | | }; |
| | | } |
| | | return { originalPostId: "", originalPostName: "æªåé
å²ä½" }; |
| | | } |
| | | |
| | | function userById(id) { |
| | | if (id == null || id === "") return undefined; |
| | | return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function filterUsersByQuery(query) { |
| | | const list = allUsersCache.value.filter((u) => isActiveUser(u)); |
| | | const q = (query || "").trim().toLowerCase(); |
| | | if (!q) return [...list]; |
| | | return list.filter((u) => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const uname = (u.userName || "").toLowerCase(); |
| | | const phone = (u.phonenumber || u.phone || "").toString(); |
| | | return nick.includes(q) || uname.includes(q) || phone.includes(q); |
| | | }); |
| | | } |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | allUsersCache.value = unwrapArray(res); |
| | | } catch { |
| | | allUsersCache.value = []; |
| | | } |
| | | } |
| | | |
| | | async function loadPostOptions() { |
| | | try { |
| | | const res = await findPostOptions(); |
| | | const rows = res.data ?? res.rows ?? []; |
| | | targetPostOptions.value = Array.isArray(rows) ? rows : []; |
| | | } catch { |
| | | targetPostOptions.value = []; |
| | | } |
| | | rebuildPostIdMap(); |
| | | } |
| | | |
| | | /** æ¥è¯¢åºï¼ä¸æè¿ç¨æ¨¡ç³ï¼æ°æ®æ¥èª userListNoPageByTenantIdï¼åç«¯è¿æ»¤ï¼ */ |
| | | const applicantSearchLoading = ref(false); |
| | | const applicantSearchOptions = ref([]); |
| | | |
| | | async function remoteSearchApplicant(query) { |
| | | applicantSearchLoading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) { |
| | | await loadUserPool(); |
| | | } |
| | | applicantSearchOptions.value = filterUsersByQuery(query); |
| | | } finally { |
| | | applicantSearchLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** 表åå
ç³è¯·äººä¸æ */ |
| | | const applicantFormSearchLoading = ref(false); |
| | | const applicantFormOptions = ref([]); |
| | | |
| | | 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.applicantName = u.nickName || u.userName || ""; |
| | | const { originalPostId, originalPostName } = resolveOriginalPost(u); |
| | | form.originalPostId = originalPostId; |
| | | form.originalPostName = originalPostName; |
| | | } else { |
| | | form.applicantName = ""; |
| | | form.originalPostId = ""; |
| | | form.originalPostName = ""; |
| | | } |
| | | } |
| | | |
| | | /** 审æ¹äººæ */ |
| | | const approverTreeData = ref([]); |
| | | const approverLabelMap = ref({}); |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload && Array.isArray(payload.data)) return payload.data; |
| | | if (payload && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function filterDisabledDept(deptList) { |
| | | if (!Array.isArray(deptList)) return []; |
| | | return deptList.filter((dept) => { |
| | | if (dept.disabled) return false; |
| | | if (dept.children?.length) { |
| | | dept.children = filterDisabledDept(dept.children); |
| | | } |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | function getUserDeptId(u) { |
| | | return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id; |
| | | } |
| | | |
| | | function getDeptNodeKey(node) { |
| | | const k = node?.id ?? node?.value ?? node?.deptId; |
| | | if (k == null || k === "") return null; |
| | | return k; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | function userToTreeLeaf(u) { |
| | | return { |
| | | id: String(u.userId ?? u.id), |
| | | label: u.nickName || u.userName || `ç¨æ·${u.userId ?? u.id}`, |
| | | }; |
| | | } |
| | | |
| | | function buildUsersByDeptId(users) { |
| | | const map = new Map(); |
| | | const unassigned = []; |
| | | for (const u of users) { |
| | | if (!isActiveUser(u)) continue; |
| | | const did = getUserDeptId(u); |
| | | if (did == null || did === "" || did === 0 || did === "0") { |
| | | unassigned.push(u); |
| | | continue; |
| | | } |
| | | const k = String(did); |
| | | if (!map.has(k)) map.set(k, []); |
| | | map.get(k).push(u); |
| | | } |
| | | return { map, unassigned }; |
| | | } |
| | | |
| | | function collectUserLabels(nodes, map) { |
| | | (nodes || []).forEach((n) => { |
| | | if (n.children?.length) { |
| | | collectUserLabels(n.children, map); |
| | | } else if (n.id != null && !String(n.id).startsWith("dept_")) { |
| | | map[String(n.id)] = n.label; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | function mergeDeptTreeWithUsers(nodes, usersByDept) { |
| | | if (!Array.isArray(nodes)) return []; |
| | | const out = []; |
| | | for (const node of nodes) { |
| | | const deptIdRaw = getDeptNodeKey(node); |
| | | if (deptIdRaw == null) continue; |
| | | const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept); |
| | | const usersHere = usersByDept.get(String(deptIdRaw)) || []; |
| | | const userChildren = usersHere.map(userToTreeLeaf); |
| | | const children = [...sub, ...userChildren]; |
| | | if (!children.length) continue; |
| | | out.push({ |
| | | id: `dept_${deptIdRaw}`, |
| | | label: node.label ?? node.deptName ?? "é¨é¨", |
| | | disabled: true, |
| | | children, |
| | | }); |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | function buildFlatApproverTree(users) { |
| | | const list = users.filter(isActiveUser).map(userToTreeLeaf); |
| | | if (!list.length) return []; |
| | | return [ |
| | | { |
| | | id: "dept_all_users", |
| | | label: "ç³»ç»ç¨æ·", |
| | | disabled: true, |
| | | children: list, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | async function loadApproverTree() { |
| | | try { |
| | | const needFetchUsers = !allUsersCache.value.length; |
| | | const [deptRes, userRes] = await Promise.all([ |
| | | deptTreeSelect(), |
| | | needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null), |
| | | ]); |
| | | let rawTree = unwrapArray(deptRes); |
| | | rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : []; |
| | | let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree))); |
| | | if (!deptTree.length && rawTree.length) { |
| | | deptTree = JSON.parse(JSON.stringify(rawTree)); |
| | | } |
| | | let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value]; |
| | | if (needFetchUsers && users.length) { |
| | | allUsersCache.value = users; |
| | | } |
| | | const { map: usersByDept, unassigned } = buildUsersByDeptId(users); |
| | | let merged = mergeDeptTreeWithUsers(deptTree, usersByDept); |
| | | if (unassigned.length) { |
| | | merged.push({ |
| | | id: "dept_unassigned", |
| | | label: "æªåé
é¨é¨", |
| | | disabled: true, |
| | | children: unassigned.map(userToTreeLeaf), |
| | | }); |
| | | } |
| | | if (!merged.length && users.length) { |
| | | merged = buildFlatApproverTree(users); |
| | | } |
| | | approverTreeData.value = merged; |
| | | const map = {}; |
| | | collectUserLabels(merged, map); |
| | | approverLabelMap.value = map; |
| | | } catch { |
| | | approverTreeData.value = []; |
| | | approverLabelMap.value = {}; |
| | | proxy?.$modal?.msgWarning?.("审æ¹äººæ°æ®å 载失败ï¼è¯·æ£æ¥ç½ç»æç¨åéè¯"); |
| | | } |
| | | } |
| | | |
| | | function resolveApproverNames(ids) { |
| | | if (!ids?.length) return ""; |
| | | const map = approverLabelMap.value; |
| | | return ids.map((id) => map[String(id)] || id).join("ã"); |
| | | } |
| | | |
| | | function approvalModeLabel(mode) { |
| | | if (mode === "countersign") return "ä¼ç¾"; |
| | | return "ä¸ç¾"; |
| | | } |
| | | |
| | | function approvalResultLabel(v) { |
| | | if (v === "approved") return "å·²éè¿"; |
| | | if (v === "rejected") return "已驳å"; |
| | | if (v === "cancelled") return "å·²æ¤é"; |
| | | return "å¾
审æ¹"; |
| | | } |
| | | |
| | | /** æ¬å°æ¨¡æåè¡¨æ°æ® */ |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | applicantId: "1001", |
| | | applicantName: "卿", |
| | | transferDate: "2026-05-20", |
| | | originalPostId: "post_dev", |
| | | originalPostName: "软件å¼åå·¥ç¨å¸", |
| | | targetPostId: "post_senior_dev", |
| | | targetPostName: "é«çº§è½¯ä»¶å¼åå·¥ç¨å¸", |
| | | approvalResult: "pending", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | }, |
| | | { |
| | | id: "2", |
| | | applicantId: "1002", |
| | | applicantName: "å´è³", |
| | | transferDate: "2026-05-10", |
| | | originalPostId: "post_pm", |
| | | originalPostName: "产åç»ç", |
| | | targetPostId: "post_senior_pm", |
| | | targetPostName: "é«çº§äº§åç»ç", |
| | | approvalResult: "approved", |
| | | approvalMode: "countersign", |
| | | approverIds: [], |
| | | approverNames: "å¼ ä¸ãæå", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantId: "", |
| | | transferDateRange: null, |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | total: 0, |
| | | }); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | if (searchForm.applicantId) { |
| | | list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId)); |
| | | } |
| | | const range = searchForm.transferDateRange; |
| | | if (range && range.length === 2) { |
| | | const [start, end] = range; |
| | | list = list.filter((r) => r.transferDate >= start && r.transferDate <= end); |
| | | } |
| | | return list.sort((a, b) => (a.transferDate < b.transferDate ? 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 list = filteredList.value; |
| | | const start = (page.current - 1) * page.size; |
| | | return list.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 100 }, |
| | | { label: "è½¬å²æ¥æ", prop: "transferDate", width: 120 }, |
| | | { label: "åå²ä½", prop: "originalPostName", minWidth: 140 }, |
| | | { label: "转å
¥å²ä½", prop: "targetPostName", minWidth: 160 }, |
| | | { |
| | | label: "审æ¹ç»æ", |
| | | prop: "approvalResult", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => approvalResultLabel(v), |
| | | formatType: (v) => { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | if (v === "cancelled") return "info"; |
| | | return "warning"; |
| | | }, |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 180, |
| | | operation: [ |
| | | { name: "ç¼è¾", type: "text", clickFun: (row) => openFormDialog("edit", row) }, |
| | | { name: "æ¥ç详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formDialog = reactive({ |
| | | visible: false, |
| | | title: "", |
| | | mode: "add", |
| | | }); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | |
| | | const formRules = { |
| | | applicantId: [{ required: true, message: "è¯·éæ©ç³è¯·äºº", trigger: "change" }], |
| | | transferDate: [{ required: true, message: "è¯·éæ©è½¬å²æ¥æ", trigger: "change" }], |
| | | originalPostName: [{ required: true, message: "åå²ä½ä¸è½ä¸ºç©º", trigger: "change" }], |
| | | targetPostId: [{ required: true, message: "è¯·éæ©è½¬å
¥å²ä½", trigger: "change" }], |
| | | approvalMode: [{ required: true, message: "è¯·éæ©å®¡æ¹æ¹å¼", trigger: "change" }], |
| | | approverIds: [{ type: "array", required: true, message: "è¯·éæ©å®¡æ¹äºº", trigger: "change" }], |
| | | }; |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | async function resetSearch() { |
| | | searchForm.applicantId = ""; |
| | | searchForm.transferDateRange = null; |
| | | handleQuery(); |
| | | await remoteSearchApplicant(""); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function ensureApplicantInFormOptions(row) { |
| | | if (!row?.applicantId) return; |
| | | const id = String(row.applicantId); |
| | | if (!applicantFormOptions.value.some((u) => String(u.userId ?? u.id) === id)) { |
| | | applicantFormOptions.value = [ |
| | | { |
| | | userId: row.applicantId, |
| | | nickName: row.applicantName, |
| | | userName: row.applicantUserName, |
| | | }, |
| | | ...applicantFormOptions.value, |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | async function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.title = mode === "add" ? "æ°å¢è°å²ç³è¯·" : "ç¼è¾è°å²ç³è¯·"; |
| | | loadApproverTree(); |
| | | Object.assign(form, createEmptyForm()); |
| | | await remoteSearchApplicantForm(""); |
| | | if (mode === "edit" && row) { |
| | | ensureApplicantInFormOptions(row); |
| | | Object.assign(form, { |
| | | id: row.id, |
| | | applicantId: row.applicantId, |
| | | applicantName: row.applicantName, |
| | | transferDate: row.transferDate, |
| | | originalPostId: row.originalPostId, |
| | | originalPostName: row.originalPostName, |
| | | targetPostId: row.targetPostId, |
| | | targetPostName: row.targetPostName, |
| | | approvalMode: row.approvalMode, |
| | | approverIds: (row.approverIds || []).map((id) => String(id)), |
| | | approverNames: row.approverNames, |
| | | }); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => formRef.value?.clearValidate?.()); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | form.approverNames = resolveApproverNames(form.approverIds); |
| | | form.targetPostName = targetPostNameById(form.targetPostId); |
| | | const payload = { |
| | | applicantId: form.applicantId, |
| | | applicantName: form.applicantName, |
| | | transferDate: form.transferDate, |
| | | originalPostId: form.originalPostId, |
| | | originalPostName: form.originalPostName, |
| | | targetPostId: form.targetPostId, |
| | | targetPostName: form.targetPostName, |
| | | approvalMode: form.approvalMode, |
| | | approverIds: [...form.approverIds], |
| | | approverNames: form.approverNames, |
| | | }; |
| | | if (formDialog.mode === "add") { |
| | | const id = `local_${Date.now()}`; |
| | | allRows.value.unshift({ |
| | | id, |
| | | ...payload, |
| | | approvalResult: "pending", |
| | | }); |
| | | proxy?.$modal?.msgSuccess?.("æ°å¢æåï¼æ¬å°æ¨¡æï¼"); |
| | | } else { |
| | | const idx = allRows.value.findIndex((r) => r.id === form.id); |
| | | const prev = idx !== -1 ? allRows.value[idx] : {}; |
| | | if (idx !== -1) { |
| | | allRows.value[idx] = { |
| | | ...prev, |
| | | id: form.id, |
| | | ...payload, |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | await Promise.all([loadUserPool(), loadPostOptions()]); |
| | | rebuildPostIdMap(); |
| | | loadApproverTree(); |
| | | await remoteSearchApplicant(""); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_title { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-regular); |
| | | } |
| | | .transfer-apply-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | .transfer-apply-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .transfer-apply-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å·¥ä½äº¤æ¥--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | | <div> |
| | | <span class="search_title">ç³è¯·äººï¼</span> |
| | | <el-select |
| | | v-model="searchForm.applicantId" |
| | | filterable |
| | | remote |
| | | clearable |
| | | reserve-keyword |
| | | placeholder="è¯·éæ©ææç´¢ç³è¯·äºº" |
| | | style="width: 220px" |
| | | :remote-method="remoteSearchApplicant" |
| | | :loading="applicantSearchLoading" |
| | | > |
| | | <el-option |
| | | v-for="u in applicantSearchOptions" |
| | | :key="u.userId" |
| | | :label="userSelectLabel(u)" |
| | | :value="u.userId" |
| | | /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">交æ¥ç¶æï¼</span> |
| | | <el-select v-model="searchForm.handoverStatus" placeholder="å
¨é¨" clearable style="width: 140px"> |
| | | <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" /> |
| | | </el-select> |
| | | <span class="search_title" style="margin-left: 12px">交æ¥ç±»åï¼</span> |
| | | <el-select v-model="searchForm.handoverType" placeholder="å
¨é¨" clearable style="width: 140px"> |
| | | <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" /> |
| | | </el-select> |
| | | <el-button type="primary" style="margin-left: 10px" @click="handleQuery">æç´¢</el-button> |
| | | <el-button @click="resetSearch">éç½®</el-button> |
| | | </div> |
| | | <div> |
| | | <el-button type="primary" @click="openFormDialog('add')">æ°å¢å·¥ä½äº¤æ¥</el-button> |
| | | </div> |
| | | </div> |
| | | <div class="table_list"> |
| | | <PIMTable |
| | | rowKey="id" |
| | | :column="tableColumn" |
| | | :tableData="tableData" |
| | | :page="page" |
| | | :isSelection="false" |
| | | :tableLoading="tableLoading" |
| | | @pagination="pagination" |
| | | :total="page.total" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ°å¢ / ç¼è¾ --> |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="720px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="work-handover-form-dialog" |
| | | @closed="onFormClosed" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="work-handover-form"> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <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="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç¦»èæ¥æ" prop="leaveDate"> |
| | | <el-date-picker |
| | | v-model="form.leaveDate" |
| | | 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="交æ¥ç¶æ" prop="handoverStatus"> |
| | | <el-select v-model="form.handoverStatus" placeholder="è¯·éæ©äº¤æ¥ç¶æ" style="width: 100%"> |
| | | <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="交æ¥ç±»å" prop="handoverType"> |
| | | <el-select v-model="form.handoverType" placeholder="è¯·éæ©äº¤æ¥ç±»å" style="width: 100%"> |
| | | <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="交æ¥äºº" prop="handoverPersonId"> |
| | | <el-select |
| | | v-model="form.handoverPersonId" |
| | | filterable |
| | | remote |
| | | clearable |
| | | reserve-keyword |
| | | placeholder="è¯·éæ©ææç´¢äº¤æ¥äºº" |
| | | style="width: 100%" |
| | | :remote-method="remoteSearchHandoverPerson" |
| | | :loading="handoverPersonSearchLoading" |
| | | @change="onHandoverPersonChange" |
| | | > |
| | | <el-option |
| | | v-for="u in handoverPersonOptions" |
| | | :key="u.userId" |
| | | :label="userSelectLabel(u)" |
| | | :value="u.userId" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å®¡æ¹æ¹å¼" prop="approvalMode"> |
| | | <el-radio-group v-model="form.approvalMode"> |
| | | <el-radio value="parallel">ä¸ç¾</el-radio> |
| | | <el-radio value="countersign">ä¼ç¾</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row :gutter="24"> |
| | | <el-col :span="24"> |
| | | <el-form-item label="审æ¹äºº" prop="approverIds"> |
| | | <el-tree-select |
| | | v-model="form.approverIds" |
| | | :data="approverTreeData" |
| | | multiple |
| | | collapse-tags |
| | | collapse-tags-tooltip |
| | | :max-collapse-tags="2" |
| | | :render-after-expand="false" |
| | | placeholder="è¯·éæ©å®¡æ¹äººï¼å¯å¤éï¼" |
| | | style="width: 100%" |
| | | :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }" |
| | | check-strictly |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="formDialog.visible = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- 详æ
--> |
| | | <el-dialog v-model="detailDialog.visible" title="å·¥ä½äº¤æ¥è¯¦æ
" width="560px" append-to-body> |
| | | <el-descriptions :column="1" border> |
| | | <el-descriptions-item label="ç³è¯·äºº">{{ detailRow.applicantName }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç¦»èæ¥æ">{{ detailRow.leaveDate || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="交æ¥ç¶æ">{{ handoverStatusLabel(detailRow.handoverStatus) }}</el-descriptions-item> |
| | | <el-descriptions-item label="交æ¥ç±»å">{{ handoverTypeLabel(detailRow.handoverType) }}</el-descriptions-item> |
| | | <el-descriptions-item label="交æ¥äºº">{{ detailRow.handoverPersonName || "â" }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹ç»æ">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item> |
| | | <el-descriptions-item label="å®¡æ¹æ¹å¼">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item> |
| | | <el-descriptions-item label="审æ¹äºº">{{ detailRow.approverNames || "â" }}</el-descriptions-item> |
| | | </el-descriptions> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="detailDialog.visible = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | | |
| | | const handoverStatusOptions = [ |
| | | { value: "in_progress", label: "è¿è¡ä¸" }, |
| | | { value: "completed", label: "已宿" }, |
| | | { value: "returned", label: "å·²éå" }, |
| | | ]; |
| | | |
| | | const handoverTypeOptions = [ |
| | | { value: "resignation", label: "离è交æ¥" }, |
| | | { value: "transfer", label: "è°å²äº¤æ¥" }, |
| | | ]; |
| | | |
| | | function handoverStatusLabel(v) { |
| | | return handoverStatusOptions.find((o) => o.value === v)?.label || "â"; |
| | | } |
| | | |
| | | function handoverTypeLabel(v) { |
| | | return handoverTypeOptions.find((o) => o.value === v)?.label || "â"; |
| | | } |
| | | |
| | | /** ä¸å端约å®åæ®µï¼æ¬å°å ä½ï¼åææ¥å£å¯¹é½ï¼ */ |
| | | const createEmptyForm = () => ({ |
| | | id: undefined, |
| | | applicantId: "", |
| | | applicantName: "", |
| | | leaveDate: "", |
| | | handoverStatus: "in_progress", |
| | | handoverType: "resignation", |
| | | handoverPersonId: "", |
| | | handoverPersonName: "", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | }); |
| | | |
| | | const allUsersCache = ref([]); |
| | | |
| | | 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) { |
| | | if (id == null || id === "") return undefined; |
| | | return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function filterUsersByQuery(query) { |
| | | const list = allUsersCache.value.filter((u) => isActiveUser(u)); |
| | | const q = (query || "").trim().toLowerCase(); |
| | | if (!q) return [...list]; |
| | | return list.filter((u) => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const uname = (u.userName || "").toLowerCase(); |
| | | const phone = (u.phonenumber || u.phone || "").toString(); |
| | | return nick.includes(q) || uname.includes(q) || phone.includes(q); |
| | | }); |
| | | } |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | allUsersCache.value = unwrapArray(res); |
| | | } catch { |
| | | allUsersCache.value = []; |
| | | } |
| | | } |
| | | |
| | | const applicantSearchLoading = ref(false); |
| | | const applicantSearchOptions = ref([]); |
| | | |
| | | async function remoteSearchApplicant(query) { |
| | | applicantSearchLoading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) { |
| | | await loadUserPool(); |
| | | } |
| | | applicantSearchOptions.value = filterUsersByQuery(query); |
| | | } finally { |
| | | applicantSearchLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | const applicantFormSearchLoading = ref(false); |
| | | const applicantFormOptions = ref([]); |
| | | |
| | | 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); |
| | | form.applicantName = u ? u.nickName || u.userName || "" : ""; |
| | | } |
| | | |
| | | const handoverPersonSearchLoading = ref(false); |
| | | const handoverPersonOptions = ref([]); |
| | | |
| | | async function remoteSearchHandoverPerson(query) { |
| | | handoverPersonSearchLoading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) { |
| | | await loadUserPool(); |
| | | } |
| | | handoverPersonOptions.value = filterUsersByQuery(query); |
| | | } finally { |
| | | handoverPersonSearchLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | function onHandoverPersonChange(uid) { |
| | | const u = userById(uid); |
| | | form.handoverPersonName = u ? u.nickName || u.userName || "" : ""; |
| | | } |
| | | |
| | | const approverTreeData = ref([]); |
| | | const approverLabelMap = ref({}); |
| | | |
| | | function unwrapArray(payload) { |
| | | if (Array.isArray(payload)) return payload; |
| | | if (payload && Array.isArray(payload.data)) return payload.data; |
| | | if (payload && Array.isArray(payload.rows)) return payload.rows; |
| | | return []; |
| | | } |
| | | |
| | | function filterDisabledDept(deptList) { |
| | | if (!Array.isArray(deptList)) return []; |
| | | return deptList.filter((dept) => { |
| | | if (dept.disabled) return false; |
| | | if (dept.children?.length) { |
| | | dept.children = filterDisabledDept(dept.children); |
| | | } |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | function getUserDeptId(u) { |
| | | return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id; |
| | | } |
| | | |
| | | function getDeptNodeKey(node) { |
| | | const k = node?.id ?? node?.value ?? node?.deptId; |
| | | if (k == null || k === "") return null; |
| | | return k; |
| | | } |
| | | |
| | | function isActiveUser(u) { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | function userToTreeLeaf(u) { |
| | | return { |
| | | id: String(u.userId ?? u.id), |
| | | label: u.nickName || u.userName || `ç¨æ·${u.userId ?? u.id}`, |
| | | }; |
| | | } |
| | | |
| | | function buildUsersByDeptId(users) { |
| | | const map = new Map(); |
| | | const unassigned = []; |
| | | for (const u of users) { |
| | | if (!isActiveUser(u)) continue; |
| | | const did = getUserDeptId(u); |
| | | if (did == null || did === "" || did === 0 || did === "0") { |
| | | unassigned.push(u); |
| | | continue; |
| | | } |
| | | const k = String(did); |
| | | if (!map.has(k)) map.set(k, []); |
| | | map.get(k).push(u); |
| | | } |
| | | return { map, unassigned }; |
| | | } |
| | | |
| | | function collectUserLabels(nodes, map) { |
| | | (nodes || []).forEach((n) => { |
| | | if (n.children?.length) { |
| | | collectUserLabels(n.children, map); |
| | | } else if (n.id != null && !String(n.id).startsWith("dept_")) { |
| | | map[String(n.id)] = n.label; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | function mergeDeptTreeWithUsers(nodes, usersByDept) { |
| | | if (!Array.isArray(nodes)) return []; |
| | | const out = []; |
| | | for (const node of nodes) { |
| | | const deptIdRaw = getDeptNodeKey(node); |
| | | if (deptIdRaw == null) continue; |
| | | const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept); |
| | | const usersHere = usersByDept.get(String(deptIdRaw)) || []; |
| | | const userChildren = usersHere.map(userToTreeLeaf); |
| | | const children = [...sub, ...userChildren]; |
| | | if (!children.length) continue; |
| | | out.push({ |
| | | id: `dept_${deptIdRaw}`, |
| | | label: node.label ?? node.deptName ?? "é¨é¨", |
| | | disabled: true, |
| | | children, |
| | | }); |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | function buildFlatApproverTree(users) { |
| | | const list = users.filter(isActiveUser).map(userToTreeLeaf); |
| | | if (!list.length) return []; |
| | | return [ |
| | | { |
| | | id: "dept_all_users", |
| | | label: "ç³»ç»ç¨æ·", |
| | | disabled: true, |
| | | children: list, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | async function loadApproverTree() { |
| | | try { |
| | | const needFetchUsers = !allUsersCache.value.length; |
| | | const [deptRes, userRes] = await Promise.all([ |
| | | deptTreeSelect(), |
| | | needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null), |
| | | ]); |
| | | let rawTree = unwrapArray(deptRes); |
| | | rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : []; |
| | | let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree))); |
| | | if (!deptTree.length && rawTree.length) { |
| | | deptTree = JSON.parse(JSON.stringify(rawTree)); |
| | | } |
| | | let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value]; |
| | | if (needFetchUsers && users.length) { |
| | | allUsersCache.value = users; |
| | | } |
| | | const { map: usersByDept, unassigned } = buildUsersByDeptId(users); |
| | | let merged = mergeDeptTreeWithUsers(deptTree, usersByDept); |
| | | if (unassigned.length) { |
| | | merged.push({ |
| | | id: "dept_unassigned", |
| | | label: "æªåé
é¨é¨", |
| | | disabled: true, |
| | | children: unassigned.map(userToTreeLeaf), |
| | | }); |
| | | } |
| | | if (!merged.length && users.length) { |
| | | merged = buildFlatApproverTree(users); |
| | | } |
| | | approverTreeData.value = merged; |
| | | const map = {}; |
| | | collectUserLabels(merged, map); |
| | | approverLabelMap.value = map; |
| | | } catch { |
| | | approverTreeData.value = []; |
| | | approverLabelMap.value = {}; |
| | | proxy?.$modal?.msgWarning?.("审æ¹äººæ°æ®å 载失败ï¼è¯·æ£æ¥ç½ç»æç¨åéè¯"); |
| | | } |
| | | } |
| | | |
| | | function resolveApproverNames(ids) { |
| | | if (!ids?.length) return ""; |
| | | const map = approverLabelMap.value; |
| | | return ids.map((id) => map[String(id)] || id).join("ã"); |
| | | } |
| | | |
| | | function approvalModeLabel(mode) { |
| | | if (mode === "countersign") return "ä¼ç¾"; |
| | | return "ä¸ç¾"; |
| | | } |
| | | |
| | | function approvalResultLabel(v) { |
| | | if (v === "approved") return "å·²éè¿"; |
| | | if (v === "rejected") return "已驳å"; |
| | | if (v === "cancelled") return "å·²æ¤é"; |
| | | return "å¾
审æ¹"; |
| | | } |
| | | |
| | | function handoverStatusTagType(v) { |
| | | if (v === "completed") return "success"; |
| | | if (v === "returned") return "danger"; |
| | | return "warning"; |
| | | } |
| | | |
| | | function handoverTypeTagType(v) { |
| | | return v === "transfer" ? "info" : ""; |
| | | } |
| | | |
| | | /** æ¬å°æ¨¡æåè¡¨æ°æ® */ |
| | | const allRows = ref([ |
| | | { |
| | | id: "1", |
| | | applicantId: "1001", |
| | | applicantName: "卿", |
| | | leaveDate: "2026-05-28", |
| | | handoverStatus: "in_progress", |
| | | handoverType: "resignation", |
| | | handoverPersonId: "1003", |
| | | handoverPersonName: "ç强", |
| | | approvalResult: "pending", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "", |
| | | }, |
| | | { |
| | | id: "2", |
| | | applicantId: "1002", |
| | | applicantName: "å´è³", |
| | | leaveDate: "2026-05-15", |
| | | handoverStatus: "completed", |
| | | handoverType: "transfer", |
| | | handoverPersonId: "1004", |
| | | handoverPersonName: "èµµæ", |
| | | approvalResult: "approved", |
| | | approvalMode: "countersign", |
| | | approverIds: [], |
| | | approverNames: "å¼ ä¸ãæå", |
| | | }, |
| | | { |
| | | id: "3", |
| | | applicantId: "1005", |
| | | applicantName: "éæµ©", |
| | | leaveDate: "2026-04-20", |
| | | handoverStatus: "returned", |
| | | handoverType: "resignation", |
| | | handoverPersonId: "1006", |
| | | handoverPersonName: "åæ´", |
| | | approvalResult: "rejected", |
| | | approvalMode: "parallel", |
| | | approverIds: [], |
| | | approverNames: "æå", |
| | | }, |
| | | ]); |
| | | |
| | | const searchForm = reactive({ |
| | | applicantId: "", |
| | | handoverStatus: "", |
| | | handoverType: "", |
| | | }); |
| | | |
| | | const tableLoading = ref(false); |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | total: 0, |
| | | }); |
| | | |
| | | const filteredList = computed(() => { |
| | | let list = [...allRows.value]; |
| | | if (searchForm.applicantId) { |
| | | list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId)); |
| | | } |
| | | if (searchForm.handoverStatus) { |
| | | list = list.filter((r) => r.handoverStatus === searchForm.handoverStatus); |
| | | } |
| | | if (searchForm.handoverType) { |
| | | list = list.filter((r) => r.handoverType === searchForm.handoverType); |
| | | } |
| | | return list.sort((a, b) => (a.leaveDate < b.leaveDate ? 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 list = filteredList.value; |
| | | const start = (page.current - 1) * page.size; |
| | | return list.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const tableColumn = ref([ |
| | | { label: "ç³è¯·äºº", prop: "applicantName", minWidth: 100 }, |
| | | { label: "ç¦»èæ¥æ", prop: "leaveDate", width: 120 }, |
| | | { |
| | | label: "交æ¥ç¶æ", |
| | | prop: "handoverStatus", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => handoverStatusLabel(v), |
| | | formatType: (v) => handoverStatusTagType(v), |
| | | }, |
| | | { |
| | | label: "交æ¥ç±»å", |
| | | prop: "handoverType", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => handoverTypeLabel(v), |
| | | formatType: (v) => handoverTypeTagType(v), |
| | | }, |
| | | { label: "交æ¥äºº", prop: "handoverPersonName", minWidth: 100 }, |
| | | { |
| | | label: "审æ¹ç»æ", |
| | | prop: "approvalResult", |
| | | width: 110, |
| | | dataType: "tag", |
| | | formatData: (v) => approvalResultLabel(v), |
| | | formatType: (v) => { |
| | | if (v === "approved") return "success"; |
| | | if (v === "rejected") return "danger"; |
| | | if (v === "cancelled") return "info"; |
| | | return "warning"; |
| | | }, |
| | | }, |
| | | { |
| | | dataType: "action", |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 200, |
| | | operation: [ |
| | | { name: "ç¼è¾", type: "text", clickFun: (row) => openFormDialog("edit", row) }, |
| | | { name: "详æ
", type: "text", clickFun: (row) => openDetail(row) }, |
| | | ], |
| | | }, |
| | | ]); |
| | | |
| | | const formDialog = reactive({ |
| | | visible: false, |
| | | title: "", |
| | | mode: "add", |
| | | }); |
| | | const formRef = ref(); |
| | | const form = reactive(createEmptyForm()); |
| | | |
| | | const formRules = { |
| | | applicantId: [{ required: true, message: "è¯·éæ©ç³è¯·äºº", trigger: "change" }], |
| | | leaveDate: [{ required: true, message: "è¯·éæ©ç¦»èæ¥æ", trigger: "change" }], |
| | | handoverStatus: [{ required: true, message: "è¯·éæ©äº¤æ¥ç¶æ", trigger: "change" }], |
| | | handoverType: [{ required: true, message: "è¯·éæ©äº¤æ¥ç±»å", trigger: "change" }], |
| | | handoverPersonId: [{ required: true, message: "è¯·éæ©äº¤æ¥äºº", trigger: "change" }], |
| | | approvalMode: [{ required: true, message: "è¯·éæ©å®¡æ¹æ¹å¼", trigger: "change" }], |
| | | approverIds: [{ type: "array", required: true, message: "è¯·éæ©å®¡æ¹äºº", trigger: "change" }], |
| | | }; |
| | | |
| | | const detailDialog = reactive({ visible: false }); |
| | | const detailRow = ref({}); |
| | | |
| | | function handleQuery() { |
| | | page.current = 1; |
| | | tableLoading.value = true; |
| | | setTimeout(() => { |
| | | tableLoading.value = false; |
| | | }, 150); |
| | | } |
| | | |
| | | async function resetSearch() { |
| | | searchForm.applicantId = ""; |
| | | searchForm.handoverStatus = ""; |
| | | searchForm.handoverType = ""; |
| | | handleQuery(); |
| | | await remoteSearchApplicant(""); |
| | | } |
| | | |
| | | function pagination(obj) { |
| | | page.current = obj.page; |
| | | page.size = obj.limit; |
| | | } |
| | | |
| | | function openDetail(row) { |
| | | detailRow.value = { ...row }; |
| | | detailDialog.visible = true; |
| | | } |
| | | |
| | | function ensureUserInOptions(optionsRef, row, idKey, nameKey) { |
| | | const id = row?.[idKey]; |
| | | if (id == null || id === "") return; |
| | | const sid = String(id); |
| | | if (!optionsRef.value.some((u) => String(u.userId ?? u.id) === sid)) { |
| | | optionsRef.value = [ |
| | | { |
| | | userId: id, |
| | | nickName: row[nameKey], |
| | | userName: row.applicantUserName, |
| | | }, |
| | | ...optionsRef.value, |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | async function openFormDialog(mode, row) { |
| | | formDialog.mode = mode; |
| | | formDialog.title = mode === "add" ? "æ°å¢å·¥ä½äº¤æ¥" : "ç¼è¾å·¥ä½äº¤æ¥"; |
| | | loadApproverTree(); |
| | | Object.assign(form, createEmptyForm()); |
| | | await Promise.all([remoteSearchApplicantForm(""), remoteSearchHandoverPerson("")]); |
| | | if (mode === "edit" && row) { |
| | | ensureUserInOptions(applicantFormOptions, row, "applicantId", "applicantName"); |
| | | ensureUserInOptions(handoverPersonOptions, row, "handoverPersonId", "handoverPersonName"); |
| | | Object.assign(form, { |
| | | id: row.id, |
| | | applicantId: row.applicantId, |
| | | applicantName: row.applicantName, |
| | | leaveDate: row.leaveDate, |
| | | handoverStatus: row.handoverStatus, |
| | | handoverType: row.handoverType, |
| | | handoverPersonId: row.handoverPersonId, |
| | | handoverPersonName: row.handoverPersonName, |
| | | approvalMode: row.approvalMode, |
| | | approverIds: (row.approverIds || []).map((id) => String(id)), |
| | | approverNames: row.approverNames, |
| | | }); |
| | | } |
| | | formDialog.visible = true; |
| | | nextTick(() => formRef.value?.clearValidate?.()); |
| | | } |
| | | |
| | | function onFormClosed() { |
| | | formRef.value?.resetFields?.(); |
| | | } |
| | | |
| | | async function submitForm() { |
| | | try { |
| | | await formRef.value?.validate?.(); |
| | | } catch { |
| | | return; |
| | | } |
| | | form.approverNames = resolveApproverNames(form.approverIds); |
| | | const payload = { |
| | | applicantId: form.applicantId, |
| | | applicantName: form.applicantName, |
| | | leaveDate: form.leaveDate, |
| | | handoverStatus: form.handoverStatus, |
| | | handoverType: form.handoverType, |
| | | handoverPersonId: form.handoverPersonId, |
| | | handoverPersonName: form.handoverPersonName, |
| | | approvalMode: form.approvalMode, |
| | | approverIds: [...form.approverIds], |
| | | approverNames: form.approverNames, |
| | | }; |
| | | if (formDialog.mode === "add") { |
| | | const id = `local_${Date.now()}`; |
| | | allRows.value.unshift({ |
| | | id, |
| | | ...payload, |
| | | approvalResult: "pending", |
| | | }); |
| | | proxy?.$modal?.msgSuccess?.("æ°å¢æåï¼æ¬å°æ¨¡æï¼"); |
| | | } else { |
| | | const idx = allRows.value.findIndex((r) => r.id === form.id); |
| | | const prev = idx !== -1 ? allRows.value[idx] : {}; |
| | | if (idx !== -1) { |
| | | allRows.value[idx] = { |
| | | ...prev, |
| | | id: form.id, |
| | | ...payload, |
| | | }; |
| | | } |
| | | proxy?.$modal?.msgSuccess?.("ä¿åæåï¼æ¬å°æ¨¡æï¼"); |
| | | } |
| | | formDialog.visible = false; |
| | | handleQuery(); |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | await loadUserPool(); |
| | | loadApproverTree(); |
| | | await remoteSearchApplicant(""); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .mb20 { |
| | | margin-bottom: 20px; |
| | | } |
| | | .search_form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | .search_title { |
| | | font-size: 14px; |
| | | color: var(--el-text-color-regular); |
| | | } |
| | | .work-handover-form :deep(.el-row) { |
| | | margin-bottom: 0; |
| | | } |
| | | .work-handover-form :deep(.el-form-item) { |
| | | margin-bottom: 18px; |
| | | } |
| | | .work-handover-form-dialog :deep(.el-dialog__body) { |
| | | padding-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- 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 ?? "", |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼è´¹ç¨æ¥é--> |
| | | <template> |
| | | <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 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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼å·®æ
æ¥é--> |
| | | <template> |
| | | <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 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, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼é¨é¨ç®¡ç--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch"> |
| | | <el-form-item label="é¨é¨åç§°" prop="deptName"> |
| | | <el-input |
| | | v-model="queryParams.deptName" |
| | | placeholder="请è¾å
¥é¨é¨åç§°" |
| | | clearable |
| | | style="width: 200px" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="ç¶æ" prop="status"> |
| | | <el-select v-model="queryParams.status" placeholder="é¨é¨ç¶æ" clearable style="width: 200px"> |
| | | <el-option |
| | | v-for="dict in sys_normal_disable" |
| | | :key="dict.value" |
| | | :label="dict.label" |
| | | :value="dict.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" icon="Search" @click="handleQuery">æç´¢</el-button> |
| | | <el-button icon="Refresh" @click="resetQuery">éç½®</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <el-row :gutter="10" class="mb8"> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="primary" |
| | | plain |
| | | icon="Plus" |
| | | @click="handleAdd" |
| | | v-hasPermi="['system:dept:add']" |
| | | >æ°å¢</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="info" |
| | | plain |
| | | icon="Sort" |
| | | @click="toggleExpandAll" |
| | | >å±å¼/æå </el-button> |
| | | </el-col> |
| | | <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar> |
| | | </el-row> |
| | | <el-table |
| | | v-if="refreshTable" |
| | | v-loading="loading" |
| | | :data="deptList" |
| | | row-key="deptId" |
| | | :default-expand-all="isExpandAll" |
| | | :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" |
| | | > |
| | | <el-table-column prop="deptName" label="é¨é¨åç§°" width="260"></el-table-column> |
| | | <el-table-column prop="orderNum" label="æåº" width="200"></el-table-column> |
| | | <el-table-column prop="status" label="ç¶æ" width="100"> |
| | | <template #default="scope"> |
| | | <dict-tag :options="sys_normal_disable" :value="scope.row.status" /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å建æ¶é´" align="center" prop="createTime" width="200"> |
| | | <template #default="scope"> |
| | | <span>{{ parseTime(scope.row.createTime) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" align="center" class-name="small-padding fixed-width"> |
| | | <template #default="scope"> |
| | | <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:dept:edit']">ä¿®æ¹</el-button> |
| | | <el-button link type="primary" icon="Plus" @click="handleAdd(scope.row)" v-hasPermi="['system:dept:add']">æ°å¢</el-button> |
| | | <el-button v-if="scope.row.parentId != 0" link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:dept:remove']">å é¤</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <!-- æ·»å æä¿®æ¹é¨é¨å¯¹è¯æ¡ --> |
| | | <el-dialog :title="title" v-model="open" width="600px" append-to-body> |
| | | <el-form ref="deptRef" :model="form" :rules="rules" label-width="80px"> |
| | | <el-row> |
| | | <el-col :span="24" v-if="form.parentId !== 0"> |
| | | <el-form-item label="ä¸çº§é¨é¨" prop="parentId"> |
| | | <el-tree-select |
| | | v-model="form.parentId" |
| | | :data="deptOptions" |
| | | :props="{ value: 'deptId', label: 'deptName', children: 'children' }" |
| | | value-key="deptId" |
| | | placeholder="éæ©ä¸çº§é¨é¨" |
| | | check-strictly |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="é¨é¨åç§°" prop="deptName"> |
| | | <el-input v-model="form.deptName" placeholder="请è¾å
¥é¨é¨åç§°" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æ¾ç¤ºæåº" prop="orderNum"> |
| | | <el-input-number v-model="form.orderNum" controls-position="right" :min="0"/> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è´è´£äºº" prop="leader"> |
| | | <el-input v-model="form.leader" placeholder="请è¾å
¥è´è´£äºº" maxlength="20" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="èç³»çµè¯" prop="phone"> |
| | | <el-input v-model="form.phone" placeholder="请è¾å
¥èç³»çµè¯" maxlength="11" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="é®ç®±" prop="email"> |
| | | <el-input v-model="form.email" placeholder="请è¾å
¥é®ç®±" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="é¨é¨ç¶æ"> |
| | | <el-radio-group v-model="form.status"> |
| | | <el-radio |
| | | v-for="dict in sys_normal_disable" |
| | | :key="dict.value" |
| | | :value="dict.value" |
| | | >{{ dict.label }}</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="é¨é¨ç¼å·" prop="deptNick"> |
| | | <el-input v-model="form.deptNick" placeholder="请è¾å
¥é¨é¨ç¼å·" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="cancel">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="Dept"> |
| | | import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/system/dept" |
| | | |
| | | const { proxy } = getCurrentInstance() |
| | | const { sys_normal_disable } = proxy.useDict("sys_normal_disable") |
| | | |
| | | const deptList = ref([]) |
| | | const open = ref(false) |
| | | const loading = ref(true) |
| | | const showSearch = ref(true) |
| | | const title = ref("") |
| | | const deptOptions = ref([]) |
| | | const isExpandAll = ref(true) |
| | | const refreshTable = ref(true) |
| | | |
| | | const data = reactive({ |
| | | form: {}, |
| | | queryParams: { |
| | | deptName: undefined, |
| | | status: undefined |
| | | }, |
| | | rules: { |
| | | parentId: [{ required: true, message: "ä¸çº§é¨é¨ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | deptName: [{ required: true, message: "é¨é¨åç§°ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | orderNum: [{ required: true, message: "æ¾ç¤ºæåºä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | email: [{ type: "email", message: "请è¾å
¥æ£ç¡®çé®ç®±å°å", trigger: ["blur", "change"] }], |
| | | phone: [{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请è¾å
¥æ£ç¡®çææºå·ç ", trigger: "blur" }], |
| | | deptNick: [{ required: true, message: "é¨é¨ç¼å·ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | }, |
| | | }) |
| | | |
| | | const { queryParams, form, rules } = toRefs(data) |
| | | |
| | | /** æ¥è¯¢é¨é¨å表 */ |
| | | function getList() { |
| | | loading.value = true |
| | | listDept(queryParams.value).then(response => { |
| | | deptList.value = proxy.handleTree(response.data, "deptId") |
| | | loading.value = false |
| | | }) |
| | | } |
| | | |
| | | /** åæ¶æé® */ |
| | | function cancel() { |
| | | open.value = false |
| | | reset() |
| | | } |
| | | |
| | | /** 表åéç½® */ |
| | | function reset() { |
| | | form.value = { |
| | | deptId: undefined, |
| | | parentId: undefined, |
| | | deptName: undefined, |
| | | orderNum: 0, |
| | | leader: undefined, |
| | | phone: undefined, |
| | | email: undefined, |
| | | status: "0", |
| | | deptNick: undefined, |
| | | } |
| | | proxy.resetForm("deptRef") |
| | | } |
| | | |
| | | /** æç´¢æé®æä½ */ |
| | | function handleQuery() { |
| | | getList() |
| | | } |
| | | |
| | | /** éç½®æé®æä½ */ |
| | | function resetQuery() { |
| | | proxy.resetForm("queryRef") |
| | | handleQuery() |
| | | } |
| | | |
| | | /** æ°å¢æé®æä½ */ |
| | | function handleAdd(row) { |
| | | reset() |
| | | listDept().then(response => { |
| | | deptOptions.value = proxy.handleTree(response.data, "deptId") |
| | | }) |
| | | if (row != undefined) { |
| | | form.value.parentId = row.deptId |
| | | } |
| | | open.value = true |
| | | title.value = "æ·»å é¨é¨" |
| | | } |
| | | |
| | | /** å±å¼/æå æä½ */ |
| | | function toggleExpandAll() { |
| | | refreshTable.value = false |
| | | isExpandAll.value = !isExpandAll.value |
| | | nextTick(() => { |
| | | refreshTable.value = true |
| | | }) |
| | | } |
| | | |
| | | /** ä¿®æ¹æé®æä½ */ |
| | | function handleUpdate(row) { |
| | | reset() |
| | | listDeptExcludeChild(row.deptId).then(response => { |
| | | deptOptions.value = proxy.handleTree(response.data, "deptId") |
| | | }) |
| | | getDept(row.deptId).then(response => { |
| | | form.value = response.data |
| | | open.value = true |
| | | title.value = "ä¿®æ¹é¨é¨" |
| | | }) |
| | | } |
| | | |
| | | /** æäº¤æé® */ |
| | | function submitForm() { |
| | | proxy.$refs["deptRef"].validate(valid => { |
| | | if (valid) { |
| | | if (form.value.deptId != undefined) { |
| | | updateDept(form.value).then(response => { |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æå") |
| | | open.value = false |
| | | getList() |
| | | }) |
| | | } else { |
| | | addDept(form.value).then(response => { |
| | | proxy.$modal.msgSuccess("æ°å¢æå") |
| | | open.value = false |
| | | getList() |
| | | }) |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** å é¤æé®æä½ */ |
| | | function handleDelete(row) { |
| | | proxy.$modal.confirm('æ¯å¦ç¡®è®¤å é¤å称为"' + row.deptName + '"çæ°æ®é¡¹?').then(function() { |
| | | return delDept(row.deptId) |
| | | }).then(() => { |
| | | getList() |
| | | proxy.$modal.msgSuccess("å 餿å") |
| | | }).catch(() => {}) |
| | | } |
| | | |
| | | getList() |
| | | </script> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼æ¥å¿ç®¡ç--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px"> |
| | | <el-form-item label="æä½å°å" prop="operIp"> |
| | | <el-input |
| | | v-model="queryParams.operIp" |
| | | placeholder="请è¾å
¥æä½å°å" |
| | | clearable |
| | | style="width: 240px;" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="ç³»ç»æ¨¡å" prop="title"> |
| | | <el-input |
| | | v-model="queryParams.title" |
| | | placeholder="请è¾å
¥ç³»ç»æ¨¡å" |
| | | clearable |
| | | style="width: 240px;" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="æä½äººå" prop="operName"> |
| | | <el-input |
| | | v-model="queryParams.operName" |
| | | placeholder="请è¾å
¥æä½äººå" |
| | | clearable |
| | | style="width: 240px;" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="ç±»å" prop="businessType"> |
| | | <el-select |
| | | v-model="queryParams.businessType" |
| | | placeholder="æä½ç±»å" |
| | | clearable |
| | | style="width: 240px" |
| | | > |
| | | <el-option |
| | | v-for="dict in sys_oper_type" |
| | | :key="dict.value" |
| | | :label="dict.label" |
| | | :value="dict.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="ç¶æ" prop="status"> |
| | | <el-select |
| | | v-model="queryParams.status" |
| | | placeholder="æä½ç¶æ" |
| | | clearable |
| | | style="width: 240px" |
| | | > |
| | | <el-option |
| | | v-for="dict in sys_common_status" |
| | | :key="dict.value" |
| | | :label="dict.label" |
| | | :value="dict.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="æä½æ¶é´" style="width: 308px"> |
| | | <el-date-picker |
| | | v-model="dateRange" |
| | | value-format="YYYY-MM-DD HH:mm:ss" |
| | | type="daterange" |
| | | range-separator="-" |
| | | start-placeholder="å¼å§æ¥æ" |
| | | end-placeholder="ç»ææ¥æ" |
| | | :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]" |
| | | ></el-date-picker> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" icon="Search" @click="handleQuery">æç´¢</el-button> |
| | | <el-button icon="Refresh" @click="resetQuery">éç½®</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <el-row :gutter="10" class="mb8"> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="danger" |
| | | plain |
| | | icon="Delete" |
| | | :disabled="multiple" |
| | | @click="handleDelete" |
| | | v-hasPermi="['monitor:operlog:remove']" |
| | | >å é¤</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="danger" |
| | | plain |
| | | icon="Delete" |
| | | @click="handleClean" |
| | | v-hasPermi="['monitor:operlog:remove']" |
| | | >æ¸
空</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button |
| | | type="warning" |
| | | plain |
| | | icon="Download" |
| | | @click="handleExport" |
| | | v-hasPermi="['monitor:operlog:export']" |
| | | >导åº</el-button> |
| | | </el-col> |
| | | <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar> |
| | | </el-row> |
| | | |
| | | <el-table ref="operlogRef" v-loading="loading" :data="operlogList" @selection-change="handleSelectionChange" :default-sort="defaultSort" @sort-change="handleSortChange"> |
| | | <el-table-column type="selection" width="50" align="center" /> |
| | | <el-table-column label="æ¥å¿ç¼å·" align="center" prop="operId" /> |
| | | <el-table-column label="ç³»ç»æ¨¡å" align="center" prop="title" :show-overflow-tooltip="true" /> |
| | | <el-table-column label="æä½ç±»å" align="center" prop="businessType"> |
| | | <template #default="scope"> |
| | | <dict-tag :options="sys_oper_type" :value="scope.row.businessType" /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½äººå" align="center" width="110" prop="operName" :show-overflow-tooltip="true" sortable="custom" :sort-orders="['descending', 'ascending']" /> |
| | | <el-table-column label="æä½å°å" align="center" prop="operIp" width="130" :show-overflow-tooltip="true" /> |
| | | <el-table-column label="æä½ç¶æ" align="center" prop="status"> |
| | | <template #default="scope"> |
| | | <dict-tag :options="sys_common_status" :value="scope.row.status" /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½æ¥æ" align="center" prop="operTime" width="180" sortable="custom" :sort-orders="['descending', 'ascending']"> |
| | | <template #default="scope"> |
| | | <span>{{ parseTime(scope.row.operTime) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æ¶èæ¶é´" align="center" prop="costTime" width="110" :show-overflow-tooltip="true" sortable="custom" :sort-orders="['descending', 'ascending']"> |
| | | <template #default="scope"> |
| | | <span>{{ scope.row.costTime }}毫ç§</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" align="center" class-name="small-padding fixed-width"> |
| | | <template #default="scope"> |
| | | <el-button link type="primary" icon="View" @click="handleView(scope.row, scope.index)" v-hasPermi="['monitor:operlog:query']">详ç»</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <pagination |
| | | v-show="total > 0" |
| | | :total="total" |
| | | v-model:page="queryParams.pageNum" |
| | | v-model:limit="queryParams.pageSize" |
| | | @pagination="getList" |
| | | /> |
| | | |
| | | <!-- æä½æ¥å¿è¯¦ç» --> |
| | | <el-dialog title="æä½æ¥å¿è¯¦ç»" v-model="open" width="800px" append-to-body> |
| | | <el-form :model="form" label-width="100px"> |
| | | <el-row> |
| | | <el-col :span="12"> |
| | | <el-form-item label="æä½æ¨¡åï¼">{{ form.title }} / {{ typeFormat(form) }}</el-form-item> |
| | | <el-form-item |
| | | label="ç»å½ä¿¡æ¯ï¼" |
| | | >{{ form.operName }} / {{ form.operIp }} / {{ form.operLocation }}</el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="请æ±å°åï¼">{{ form.operUrl }}</el-form-item> |
| | | <el-form-item label="è¯·æ±æ¹å¼ï¼">{{ form.requestMethod }}</el-form-item> |
| | | </el-col> |
| | | <el-col :span="24"> |
| | | <el-form-item label="æä½æ¹æ³ï¼">{{ form.method }}</el-form-item> |
| | | </el-col> |
| | | <el-col :span="24"> |
| | | <el-form-item label="请æ±åæ°ï¼">{{ form.operParam }}</el-form-item> |
| | | </el-col> |
| | | <el-col :span="24"> |
| | | <el-form-item label="è¿ååæ°ï¼">{{ form.jsonResult }}</el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æä½ç¶æï¼"> |
| | | <div v-if="form.status === 0">æ£å¸¸</div> |
| | | <div v-else-if="form.status === 1">失败</div> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æ¶èæ¶é´ï¼">{{ form.costTime }}毫ç§</el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="æä½æ¶é´ï¼">{{ parseTime(form.operTime) }}</el-form-item> |
| | | </el-col> |
| | | <el-col :span="24"> |
| | | <el-form-item label="å¼å¸¸ä¿¡æ¯ï¼" v-if="form.status === 1">{{ form.errorMsg }}</el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button @click="open = false">å
³ é</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="Operlog"> |
| | | import { list, delOperlog, cleanOperlog } from "@/api/monitor/operlog" |
| | | import {onMounted} from "vue"; |
| | | |
| | | const { proxy } = getCurrentInstance() |
| | | const { sys_oper_type, sys_common_status } = proxy.useDict("sys_oper_type","sys_common_status") |
| | | |
| | | const operlogList = ref([]) |
| | | const open = ref(false) |
| | | const loading = ref(true) |
| | | const showSearch = ref(true) |
| | | const ids = ref([]) |
| | | const single = ref(true) |
| | | const multiple = ref(true) |
| | | const total = ref(0) |
| | | const title = ref("") |
| | | const dateRange = ref([]) |
| | | const defaultSort = ref({ prop: "operTime", order: "descending" }) |
| | | |
| | | const data = reactive({ |
| | | form: {}, |
| | | queryParams: { |
| | | pageNum: 1, |
| | | pageSize: 10, |
| | | operIp: undefined, |
| | | title: undefined, |
| | | operName: undefined, |
| | | businessType: undefined, |
| | | status: undefined |
| | | } |
| | | }) |
| | | |
| | | const { queryParams, form } = toRefs(data) |
| | | |
| | | /** æ¥è¯¢ç»å½æ¥å¿ */ |
| | | function getList() { |
| | | loading.value = true |
| | | list(proxy.addDateRange(queryParams.value, dateRange.value)).then(response => { |
| | | operlogList.value = response.rows |
| | | total.value = response.total |
| | | loading.value = false |
| | | }) |
| | | } |
| | | |
| | | /** æä½æ¥å¿ç±»ååå
¸ç¿»è¯ */ |
| | | function typeFormat(row, column) { |
| | | return proxy.selectDictLabel(sys_oper_type.value, row.businessType) |
| | | } |
| | | |
| | | /** æç´¢æé®æä½ */ |
| | | function handleQuery() { |
| | | queryParams.value.pageNum = 1 |
| | | getList() |
| | | } |
| | | |
| | | /** éç½®æé®æä½ */ |
| | | function resetQuery() { |
| | | dateRange.value = [] |
| | | proxy.resetForm("queryRef") |
| | | queryParams.value.pageNum = 1 |
| | | proxy.$refs["operlogRef"].sort(defaultSort.value.prop, defaultSort.value.order) |
| | | } |
| | | |
| | | /** å¤éæ¡é䏿°æ® */ |
| | | function handleSelectionChange(selection) { |
| | | ids.value = selection.map(item => item.operId) |
| | | multiple.value = !selection.length |
| | | } |
| | | |
| | | /** æåºè§¦åäºä»¶ */ |
| | | function handleSortChange(column, prop, order) { |
| | | queryParams.value.orderByColumn = column.prop |
| | | queryParams.value.isAsc = column.order |
| | | getList() |
| | | } |
| | | |
| | | /** è¯¦ç»æé®æä½ */ |
| | | function handleView(row) { |
| | | open.value = true |
| | | form.value = row |
| | | } |
| | | |
| | | /** å é¤æé®æä½ */ |
| | | function handleDelete(row) { |
| | | const operIds = row.operId || ids.value |
| | | proxy.$modal.confirm('æ¯å¦ç¡®è®¤å 餿¥å¿ç¼å·ä¸º"' + operIds + '"çæ°æ®é¡¹?').then(function () { |
| | | return delOperlog(operIds) |
| | | }).then(() => { |
| | | getList() |
| | | proxy.$modal.msgSuccess("å 餿å") |
| | | }).catch(() => {}) |
| | | } |
| | | |
| | | /** æ¸
空æé®æä½ */ |
| | | function handleClean() { |
| | | proxy.$modal.confirm("æ¯å¦ç¡®è®¤æ¸
ç©ºæææä½æ¥å¿æ°æ®é¡¹?").then(function () { |
| | | return cleanOperlog() |
| | | }).then(() => { |
| | | getList() |
| | | proxy.$modal.msgSuccess("æ¸
空æå") |
| | | }).catch(() => {}) |
| | | } |
| | | |
| | | /** å¯¼åºæé®æä½ */ |
| | | function handleExport() { |
| | | proxy.download("monitor/operlog/export",{ |
| | | ...queryParams.value, |
| | | }, `config_${new Date().getTime()}.xlsx`) |
| | | } |
| | | |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | </script> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container"> |
| | | <h4 class="form-header h4">åºæ¬ä¿¡æ¯</h4> |
| | | <el-form :model="form" label-width="80px"> |
| | | <el-row> |
| | | <el-col :span="8" :offset="2"> |
| | | <el-form-item label="ç¨æ·æµç§°" prop="nickName"> |
| | | <el-input v-model="form.nickName" disabled /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8" :offset="2"> |
| | | <el-form-item label="ç»å½è´¦å·" prop="userName"> |
| | | <el-input v-model="form.userName" disabled /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | |
| | | <h4 class="form-header h4">è§è²ä¿¡æ¯</h4> |
| | | <el-table v-loading="loading" :row-key="getRowKey" @row-click="clickRow" ref="roleRef" @selection-change="handleSelectionChange" :data="roles.slice((pageNum - 1) * pageSize, pageNum * pageSize)"> |
| | | <el-table-column label="åºå·" width="55" type="index" align="center"> |
| | | <template #default="scope"> |
| | | <span>{{ (pageNum - 1) * pageSize + scope.$index + 1 }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column type="selection" :reserve-selection="true" :selectable="checkSelectable" width="55"></el-table-column> |
| | | <el-table-column label="è§è²ç¼å·" align="center" prop="roleId" /> |
| | | <el-table-column label="è§è²åç§°" align="center" prop="roleName" /> |
| | | <el-table-column label="æéå符" align="center" prop="roleKey" /> |
| | | <el-table-column label="å建æ¶é´" align="center" prop="createTime" width="180"> |
| | | <template #default="scope"> |
| | | <span>{{ parseTime(scope.row.createTime) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize" /> |
| | | |
| | | <el-form label-width="100px"> |
| | | <div style="text-align: center;margin-left:-120px;margin-top:30px;"> |
| | | <el-button type="primary" @click="submitForm()">æäº¤</el-button> |
| | | <el-button @click="close()">è¿å</el-button> |
| | | </div> |
| | | </el-form> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="AuthRole"> |
| | | import { getAuthRole, updateAuthRole } from "@/api/system/user" |
| | | |
| | | const route = useRoute() |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | const loading = ref(true) |
| | | const total = ref(0) |
| | | const pageNum = ref(1) |
| | | const pageSize = ref(10) |
| | | const roleIds = ref([]) |
| | | const roles = ref([]) |
| | | const form = ref({ |
| | | nickName: undefined, |
| | | userName: undefined, |
| | | userId: undefined |
| | | }) |
| | | |
| | | /** åå»éä¸è¡æ°æ® */ |
| | | function clickRow(row) { |
| | | if (checkSelectable(row)) { |
| | | proxy.$refs["roleRef"].toggleRowSelection(row) |
| | | } |
| | | } |
| | | |
| | | /** å¤éæ¡é䏿°æ® */ |
| | | function handleSelectionChange(selection) { |
| | | roleIds.value = selection.map(item => item.roleId) |
| | | } |
| | | |
| | | /** ä¿åéä¸çæ°æ®ç¼å· */ |
| | | function getRowKey(row) { |
| | | return row.roleId |
| | | } |
| | | |
| | | // æ£æ¥è§è²ç¶æ |
| | | function checkSelectable(row) { |
| | | return row.status === "0" ? true : false |
| | | } |
| | | |
| | | /** å
³éæé® */ |
| | | function close() { |
| | | const obj = { path: "/system/user" } |
| | | proxy.$tab.closeOpenPage(obj) |
| | | } |
| | | |
| | | /** æäº¤æé® */ |
| | | function submitForm() { |
| | | const userId = form.value.userId |
| | | const rIds = roleIds.value.join(",") |
| | | updateAuthRole({ userId: userId, roleIds: rIds }).then(response => { |
| | | proxy.$modal.msgSuccess("æææå") |
| | | close() |
| | | }) |
| | | } |
| | | |
| | | (() => { |
| | | const userId = route.params && route.params.userId |
| | | if (userId) { |
| | | loading.value = true |
| | | getAuthRole(userId).then(response => { |
| | | form.value = response.user |
| | | roles.value = response.roles |
| | | total.value = roles.value.length |
| | | nextTick(() => { |
| | | roles.value.forEach(row => { |
| | | if (row.flag) { |
| | | proxy.$refs["roleRef"].toggleRowSelection(row) |
| | | } |
| | | }) |
| | | }) |
| | | loading.value = false |
| | | }) |
| | | } |
| | | })() |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼ç¨æ·ç®¡ç--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-row :gutter="20" style="height: calc(100vh - 8em)"> |
| | | <splitpanes :horizontal="appStore.device === 'mobile'" class="default-theme"> |
| | | <!--é¨é¨æ°æ®--> |
| | | <pane size="16"> |
| | | <el-col style="padding: 10px"> |
| | | <div class="head-container"> |
| | | <el-input v-model="deptNames" placeholder="请è¾å
¥é¨é¨åç§°" clearable prefix-icon="Search" style="margin-bottom: 20px" /> |
| | | </div> |
| | | <div class="head-container"> |
| | | <el-tree :data="deptOptions" :props="{ label: 'label', children: 'children' }" :expand-on-click-node="false" :filter-node-method="filterNode" ref="deptTreeRef" node-key="id" highlight-current default-expand-all @node-click="handleNodeClick" /> |
| | | </div> |
| | | </el-col> |
| | | </pane> |
| | | <!--ç¨æ·æ°æ®--> |
| | | <pane size="84"> |
| | | <el-col style="padding: 10px; height: 100%; display: flex; flex-direction: column;"> |
| | | <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px"> |
| | | <el-form-item label="ç»å½è´¦å·" prop="userName"> |
| | | <el-input v-model="queryParams.userName" placeholder="请è¾å
¥ç»å½è´¦å·" clearable style="width: 240px" @keyup.enter="handleQuery" /> |
| | | </el-form-item> |
| | | <el-form-item label="ææºå·ç " prop="phonenumber"> |
| | | <el-input v-model="queryParams.phonenumber" placeholder="请è¾å
¥ææºå·ç " clearable style="width: 240px" @keyup.enter="handleQuery" /> |
| | | </el-form-item> |
| | | <el-form-item label="ç¶æ" prop="status"> |
| | | <el-select v-model="queryParams.status" placeholder="ç¨æ·ç¶æ" clearable style="width: 240px"> |
| | | <el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="å建æ¶é´" style="width: 308px"> |
| | | <el-date-picker v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" range-separator="-" start-placeholder="å¼å§æ¥æ" end-placeholder="ç»ææ¥æ"></el-date-picker> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" icon="Search" @click="handleQuery">æç´¢</el-button> |
| | | <el-button icon="Refresh" @click="resetQuery">éç½®</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <el-row :gutter="10" class="mb8"> |
| | | <el-col :span="1.5"> |
| | | <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['system:user:add']">æ°å¢</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['system:user:edit']">ä¿®æ¹</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['system:user:remove']">å é¤</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button type="info" plain icon="Upload" @click="handleImport" v-hasPermi="['system:user:import']">导å
¥</el-button> |
| | | </el-col> |
| | | <el-col :span="1.5"> |
| | | <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['system:user:export']">导åº</el-button> |
| | | </el-col> |
| | | <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar> |
| | | </el-row> |
| | | |
| | | <div style="flex: 1; overflow: hidden;"> |
| | | <el-table v-loading="loading" :data="userList" height="100%" @selection-change="handleSelectionChange"> |
| | | <el-table-column type="selection" width="50" align="center" /> |
| | | <el-table-column label="ç¨æ·ç¼å·" align="center" key="userId" prop="userId" v-if="columns[0].visible" /> |
| | | <el-table-column label="ç»å½è´¦å·" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" /> |
| | | <el-table-column label="ç¨æ·æµç§°" align="center" key="nickName" prop="nickName" v-if="columns[2].visible" :show-overflow-tooltip="true" /> |
| | | <el-table-column label="é¨é¨" align="center" key="deptNames" prop="deptNames" v-if="columns[3].visible" :show-overflow-tooltip="true" /> |
| | | <el-table-column label="ææºå·ç " align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" /> |
| | | <el-table-column label="ç¶æ" align="center" key="status" v-if="columns[5].visible"> |
| | | <template #default="scope"> |
| | | <el-switch |
| | | v-model="scope.row.status" |
| | | active-value="0" |
| | | inactive-value="1" |
| | | @change="handleStatusChange(scope.row)" |
| | | ></el-switch> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å建æ¶é´" align="center" prop="createTime" v-if="columns[6].visible" width="160"> |
| | | <template #default="scope"> |
| | | <span>{{ parseTime(scope.row.createTime) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" align="center" width="150" class-name="small-padding fixed-width"> |
| | | <template #default="scope"> |
| | | <el-tooltip content="ä¿®æ¹" placement="top" v-if="scope.row.userId !== 1"> |
| | | <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:user:edit']"></el-button> |
| | | </el-tooltip> |
| | | <el-tooltip content="å é¤" placement="top" v-if="scope.row.userId !== 1"> |
| | | <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:user:remove']"></el-button> |
| | | </el-tooltip> |
| | | <el-tooltip content="éç½®å¯ç " placement="top" v-if="scope.row.userId !== 1"> |
| | | <el-button link type="primary" icon="Key" @click="handleResetPwd(scope.row)" v-hasPermi="['system:user:resetPwd']"></el-button> |
| | | </el-tooltip> |
| | | <el-tooltip content="åé
è§è²" placement="top" v-if="scope.row.userId !== 1"> |
| | | <el-button link type="primary" icon="CircleCheck" @click="handleAuthRole(scope.row)" v-hasPermi="['system:user:edit']"></el-button> |
| | | </el-tooltip> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" /> |
| | | </el-col> |
| | | </pane> |
| | | </splitpanes> |
| | | </el-row> |
| | | |
| | | <!-- æ·»å æä¿®æ¹ç¨æ·é
ç½®å¯¹è¯æ¡ --> |
| | | <el-dialog :title="title" v-model="open" width="600px" append-to-body> |
| | | <el-form :model="form" :rules="rules" ref="userRef" label-width="80px"> |
| | | <el-row> |
| | | <el-col :span="12"> |
| | | <el-form-item v-if="form.userId == undefined" label="ç»å½è´¦å·" prop="userName"> |
| | | <el-input v-model="form.userName" placeholder="请è¾å
¥ç¨æ·åç§°" maxlength="30" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item v-if="form.userId == undefined" label="ç¨æ·å¯ç " prop="password"> |
| | | <el-input v-model="form.password" placeholder="请è¾å
¥ç¨æ·å¯ç " type="password" maxlength="20" show-password /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç¨æ·æµç§°" prop="nickName"> |
| | | <el-input v-model="form.nickName" placeholder="请è¾å
¥ç¨æ·æµç§°" maxlength="30" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å½å±é¨é¨" prop="deptId"> |
| | | <el-tree-select v-model="form.deptId" :data="enabledDeptOptions" :props="{ value: 'id', label: 'label', children: 'children' }" value-key="id" placeholder="è¯·éæ©å½å±é¨é¨" check-strictly /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row> |
| | | <el-col :span="12"> |
| | | <el-form-item label="å²ä½" prop="postIds"> |
| | | <el-select v-model="form.postIds" multiple placeholder="è¯·éæ©"> |
| | | <el-option v-for="item in postOptions" :key="item.postId" :label="item.postName" :value="item.postId" :disabled="item.status == 1"></el-option> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="è§è²" prop="roleIds"> |
| | | <el-select v-model="form.roleIds" multiple placeholder="è¯·éæ©"> |
| | | <el-option v-for="item in roleOptions" :key="item.roleId" :label="item.roleName" :value="item.roleId" :disabled="item.status == 1"></el-option> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ææºå·ç " prop="phonenumber"> |
| | | <el-input v-model="form.phonenumber" placeholder="请è¾å
¥ææºå·ç " maxlength="11" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="é®ç®±" prop="email"> |
| | | <el-input v-model="form.email" placeholder="请è¾å
¥é®ç®±" maxlength="50" /> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç¨æ·æ§å«"> |
| | | <el-select v-model="form.sex" placeholder="è¯·éæ©"> |
| | | <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label" :value="dict.value"></el-option> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="ç¶æ"> |
| | | <el-radio-group v-model="form.status"> |
| | | <el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-row> |
| | | <el-col :span="24"> |
| | | <el-form-item label="夿³¨"> |
| | | <el-input v-model="form.remark" type="textarea" placeholder="请è¾å
¥å
容"></el-input> |
| | | </el-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | </el-form> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitForm">ç¡® å®</el-button> |
| | | <el-button @click="cancel">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- ç¨æ·å¯¼å
¥å¯¹è¯æ¡ --> |
| | | <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body> |
| | | <el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url + '?updateSupport=' + upload.updateSupport" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag> |
| | | <el-icon class="el-icon--upload"><upload-filled /></el-icon> |
| | | <div class="el-upload__text">å°æä»¶æå°æ¤å¤ï¼æ<em>ç¹å»ä¸ä¼ </em></div> |
| | | <template #tip> |
| | | <div class="el-upload__tip text-center"> |
| | | <div class="el-upload__tip"> |
| | | <el-checkbox v-model="upload.updateSupport" />æ¯å¦æ´æ°å·²ç»åå¨çç¨æ·æ°æ® |
| | | </div> |
| | | <span>ä»
å
许导å
¥xlsãxlsxæ ¼å¼æä»¶ã</span> |
| | | <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline" @click="importTemplate">ä¸è½½æ¨¡æ¿</el-link> |
| | | </div> |
| | | </template> |
| | | </el-upload> |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button type="primary" @click="submitFileForm">ç¡® å®</el-button> |
| | | <el-button @click="upload.open = false">å æ¶</el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="User"> |
| | | import { getToken } from "@/utils/auth" |
| | | import useAppStore from '@/store/modules/app' |
| | | import { changeUserStatus, listUser, resetUserPwd, delUser, getUser, updateUser, addUser, deptTreeSelect } from "@/api/system/user" |
| | | import { Splitpanes, Pane } from "splitpanes" |
| | | import "splitpanes/dist/splitpanes.css" |
| | | |
| | | const router = useRouter() |
| | | const appStore = useAppStore() |
| | | const { proxy } = getCurrentInstance() |
| | | const { sys_normal_disable, sys_user_sex } = proxy.useDict("sys_normal_disable", "sys_user_sex") |
| | | |
| | | const userList = ref([]) |
| | | const open = ref(false) |
| | | const loading = ref(true) |
| | | const showSearch = ref(true) |
| | | const ids = ref([]) |
| | | const single = ref(true) |
| | | const multiple = ref(true) |
| | | const total = ref(0) |
| | | const title = ref("") |
| | | const dateRange = ref([]) |
| | | const deptNames = ref("") |
| | | const deptOptions = ref(undefined) |
| | | const enabledDeptOptions = ref(undefined) |
| | | const initPassword = ref(undefined) |
| | | const postOptions = ref([]) |
| | | const roleOptions = ref([]) |
| | | /*** ç¨æ·å¯¼å
¥åæ° */ |
| | | const upload = reactive({ |
| | | // æ¯å¦æ¾ç¤ºå¼¹åºå±ï¼ç¨æ·å¯¼å
¥ï¼ |
| | | open: false, |
| | | // å¼¹åºå±æ é¢ï¼ç¨æ·å¯¼å
¥ï¼ |
| | | title: "", |
| | | // æ¯å¦ç¦ç¨ä¸ä¼ |
| | | isUploading: false, |
| | | // æ¯å¦æ´æ°å·²ç»åå¨çç¨æ·æ°æ® |
| | | updateSupport: 0, |
| | | // 设置ä¸ä¼ ç请æ±å¤´é¨ |
| | | headers: { Authorization: "Bearer " + getToken() }, |
| | | // ä¸ä¼ çå°å |
| | | url: import.meta.env.VITE_APP_BASE_API + "/system/user/importData" |
| | | }) |
| | | // åæ¾éä¿¡æ¯ |
| | | const columns = ref([ |
| | | { key: 0, label: `ç¨æ·ç¼å·`, visible: true }, |
| | | { key: 1, label: `ç»å½è´¦å·`, visible: true }, |
| | | { key: 2, label: `ç¨æ·æµç§°`, visible: true }, |
| | | { key: 3, label: `é¨é¨`, visible: true }, |
| | | { key: 4, label: `ææºå·ç `, visible: true }, |
| | | { key: 5, label: `ç¶æ`, visible: true }, |
| | | { key: 6, label: `å建æ¶é´`, visible: true } |
| | | ]) |
| | | |
| | | const data = reactive({ |
| | | form: {}, |
| | | queryParams: { |
| | | pageNum: 1, |
| | | pageSize: 10, |
| | | userName: undefined, |
| | | phonenumber: undefined, |
| | | status: undefined, |
| | | deptId: undefined |
| | | }, |
| | | rules: { |
| | | userName: [{ required: true, message: "ç¨æ·åç§°ä¸è½ä¸ºç©º", trigger: "blur" }, { min: 2, max: 20, message: "ç¨æ·åç§°é¿åº¦å¿
é¡»ä»äº 2 å 20 ä¹é´", trigger: "blur" }], |
| | | nickName: [{ required: true, message: "ç¨æ·æµç§°ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | password: [{ required: true, message: "ç¨æ·å¯ç ä¸è½ä¸ºç©º", trigger: "blur" }, { min: 5, max: 20, message: "ç¨æ·å¯ç é¿åº¦å¿
é¡»ä»äº 5 å 20 ä¹é´", trigger: "blur" }, { pattern: /^[^<>"'|\\]+$/, message: "ä¸è½å
å«éæ³å符ï¼< > \" ' \\\ |", trigger: "blur" }], |
| | | email: [{ type: "email", message: "请è¾å
¥æ£ç¡®çé®ç®±å°å", trigger: ["blur", "change"] }], |
| | | phonenumber: [{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请è¾å
¥æ£ç¡®çææºå·ç ", trigger: "blur" }], |
| | | deptId: [{ required: true, message: "å½å±é¨é¨ä¸è½ä¸ºç©º", trigger: "change" }], |
| | | postIds: [{ required: true, message: "å²ä½ä¸è½ä¸ºç©º", trigger: "change" }], |
| | | roleIds: [{ required: true, message: "è§è²ä¸è½ä¸ºç©º", trigger: "change" }] |
| | | } |
| | | }) |
| | | |
| | | const { queryParams, form, rules } = toRefs(data) |
| | | |
| | | /** éè¿æ¡ä»¶è¿æ»¤èç¹ */ |
| | | const filterNode = (value, data) => { |
| | | if (!value) return true |
| | | return data.label.indexOf(value) !== -1 |
| | | } |
| | | |
| | | /** æ ¹æ®åç§°çéé¨é¨æ */ |
| | | watch(deptNames, val => { |
| | | proxy.$refs["deptTreeRef"].filter(val) |
| | | }) |
| | | |
| | | /** æ¥è¯¢ç¨æ·å表 */ |
| | | function getList() { |
| | | loading.value = true |
| | | listUser(proxy.addDateRange(queryParams.value, dateRange.value)).then(res => { |
| | | loading.value = false |
| | | userList.value = res.rows |
| | | total.value = res.total |
| | | }) |
| | | } |
| | | |
| | | /** æ¥è¯¢é¨é¨ä¸ææ ç»æ */ |
| | | function getDeptTree() { |
| | | deptTreeSelect().then(response => { |
| | | deptOptions.value = response.data |
| | | enabledDeptOptions.value = filterDisabledDept(JSON.parse(JSON.stringify(response.data))) |
| | | }) |
| | | } |
| | | |
| | | /** è¿æ»¤ç¦ç¨çé¨é¨ */ |
| | | function filterDisabledDept(deptList) { |
| | | return deptList.filter(dept => { |
| | | if (dept.disabled) { |
| | | return false |
| | | } |
| | | if (dept.children && dept.children.length) { |
| | | dept.children = filterDisabledDept(dept.children) |
| | | } |
| | | return true |
| | | }) |
| | | } |
| | | |
| | | /** èç¹åå»äºä»¶ */ |
| | | function handleNodeClick(data) { |
| | | queryParams.value.deptId = data.id |
| | | handleQuery() |
| | | } |
| | | |
| | | /** æç´¢æé®æä½ */ |
| | | function handleQuery() { |
| | | queryParams.value.pageNum = 1 |
| | | getList() |
| | | } |
| | | |
| | | /** éç½®æé®æä½ */ |
| | | function resetQuery() { |
| | | dateRange.value = [] |
| | | proxy.resetForm("queryRef") |
| | | queryParams.value.deptId = undefined |
| | | proxy.$refs.deptTreeRef.setCurrentKey(null) |
| | | handleQuery() |
| | | } |
| | | |
| | | /** å é¤æé®æä½ */ |
| | | function handleDelete(row) { |
| | | const userIds = row.userId || ids.value |
| | | proxy.$modal.confirm('æ¯å¦ç¡®è®¤å é¤ç¨æ·ç¼å·ä¸º"' + userIds + '"çæ°æ®é¡¹ï¼').then(function () { |
| | | return delUser(userIds) |
| | | }).then(() => { |
| | | getList() |
| | | proxy.$modal.msgSuccess("å 餿å") |
| | | }).catch(() => {}) |
| | | } |
| | | |
| | | /** å¯¼åºæé®æä½ */ |
| | | function handleExport() { |
| | | proxy.download("system/user/export", { |
| | | ...queryParams.value, |
| | | },`user_${new Date().getTime()}.xlsx`) |
| | | } |
| | | |
| | | /** ç¨æ·ç¶æä¿®æ¹ */ |
| | | function handleStatusChange(row) { |
| | | let text = row.status === "0" ? "å¯ç¨" : "åç¨" |
| | | proxy.$modal.confirm('确认è¦"' + text + '""' + row.userName + '"ç¨æ·å?').then(function () { |
| | | return changeUserStatus(row.userId, row.status) |
| | | }).then(() => { |
| | | proxy.$modal.msgSuccess(text + "æå") |
| | | }).catch(function () { |
| | | row.status = row.status === "0" ? "1" : "0" |
| | | }) |
| | | } |
| | | |
| | | /** æ´å¤æä½ */ |
| | | function handleCommand(command, row) { |
| | | switch (command) { |
| | | case "handleResetPwd": |
| | | handleResetPwd(row) |
| | | break |
| | | case "handleAuthRole": |
| | | handleAuthRole(row) |
| | | break |
| | | default: |
| | | break |
| | | } |
| | | } |
| | | |
| | | /** 跳转è§è²åé
*/ |
| | | function handleAuthRole(row) { |
| | | const userId = row.userId |
| | | router.push("/system/user-auth/role/" + userId) |
| | | } |
| | | |
| | | /** éç½®å¯ç æé®æä½ */ |
| | | function handleResetPwd(row) { |
| | | proxy.$prompt('请è¾å
¥"' + row.userName + '"çæ°å¯ç ', "æç¤º", { |
| | | confirmButtonText: "ç¡®å®", |
| | | cancelButtonText: "åæ¶", |
| | | closeOnClickModal: false, |
| | | inputPattern: /^.{5,20}$/, |
| | | inputErrorMessage: "ç¨æ·å¯ç é¿åº¦å¿
é¡»ä»äº 5 å 20 ä¹é´", |
| | | inputValidator: (value) => { |
| | | if (/<|>|"|'|\||\\/.test(value)) { |
| | | return "ä¸è½å
å«éæ³å符ï¼< > \" ' \\\ |" |
| | | } |
| | | }, |
| | | }).then(({ value }) => { |
| | | resetUserPwd(row.userId, value).then(response => { |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æåï¼æ°å¯ç æ¯ï¼" + value) |
| | | }) |
| | | }).catch(() => {}) |
| | | } |
| | | |
| | | /** éæ©æ¡æ° */ |
| | | function handleSelectionChange(selection) { |
| | | ids.value = selection.map(item => item.userId) |
| | | single.value = selection.length != 1 |
| | | multiple.value = !selection.length |
| | | } |
| | | |
| | | /** 导å
¥æé®æä½ */ |
| | | function handleImport() { |
| | | upload.title = "ç¨æ·å¯¼å
¥" |
| | | upload.open = true |
| | | } |
| | | |
| | | /** ä¸è½½æ¨¡æ¿æä½ */ |
| | | function importTemplate() { |
| | | proxy.download("system/user/importTemplate", { |
| | | }, `user_template_${new Date().getTime()}.xlsx`) |
| | | } |
| | | |
| | | /**æä»¶ä¸ä¼ ä¸å¤ç */ |
| | | const handleFileUploadProgress = (event, file, fileList) => { |
| | | upload.isUploading = true |
| | | } |
| | | |
| | | /** æä»¶ä¸ä¼ æåå¤ç */ |
| | | const handleFileSuccess = (response, file, fileList) => { |
| | | upload.open = false |
| | | upload.isUploading = false |
| | | proxy.$refs["uploadRef"].handleRemove(file) |
| | | proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导å
¥ç»æ", { dangerouslyUseHTMLString: true }) |
| | | getList() |
| | | } |
| | | |
| | | /** æäº¤ä¸ä¼ æä»¶ */ |
| | | function submitFileForm() { |
| | | proxy.$refs["uploadRef"].submit() |
| | | } |
| | | |
| | | /** éç½®æä½è¡¨å */ |
| | | function reset() { |
| | | form.value = { |
| | | userId: undefined, |
| | | deptId: undefined, |
| | | userName: undefined, |
| | | nickName: undefined, |
| | | password: undefined, |
| | | phonenumber: undefined, |
| | | email: undefined, |
| | | sex: undefined, |
| | | status: "0", |
| | | remark: undefined, |
| | | postIds: [], |
| | | roleIds: [] |
| | | } |
| | | proxy.resetForm("userRef") |
| | | } |
| | | |
| | | /** åæ¶æé® */ |
| | | function cancel() { |
| | | open.value = false |
| | | reset() |
| | | } |
| | | |
| | | /** æ°å¢æé®æä½ */ |
| | | function handleAdd() { |
| | | reset() |
| | | getUser().then(response => { |
| | | postOptions.value = response.posts |
| | | roleOptions.value = response.roles |
| | | open.value = true |
| | | title.value = "æ·»å ç¨æ·" |
| | | form.value.password = initPassword.value |
| | | }) |
| | | } |
| | | |
| | | /** ä¿®æ¹æé®æä½ */ |
| | | function handleUpdate(row) { |
| | | reset() |
| | | const userId = row.userId || ids.value |
| | | getUser(userId).then(response => { |
| | | form.value = response.data |
| | | postOptions.value = response.posts |
| | | roleOptions.value = response.roles |
| | | form.value.postIds = response.postIds |
| | | form.value.roleIds = response.roleIds |
| | | open.value = true |
| | | title.value = "ä¿®æ¹ç¨æ·" |
| | | form.password = "" |
| | | }) |
| | | } |
| | | |
| | | /** æäº¤æé® */ |
| | | function submitForm() { |
| | | proxy.$refs["userRef"].validate(valid => { |
| | | if (valid) { |
| | | // å½å±é¨é¨è½ç¶æ¯åéï¼ä½å端éè¦ä¼ æ°ç»å段 deptIds |
| | | const payload = { |
| | | ...form.value, |
| | | deptIds: form.value.deptId ? [form.value.deptId] : [] |
| | | } |
| | | if (form.value.userId != undefined) { |
| | | updateUser(payload).then(response => { |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æå") |
| | | open.value = false |
| | | getList() |
| | | }) |
| | | } else { |
| | | addUser(payload).then(response => { |
| | | proxy.$modal.msgSuccess("æ°å¢æå") |
| | | open.value = false |
| | | getList() |
| | | }) |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | |
| | | getDeptTree() |
| | | getList() |
| | | </script> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="6" :xs="24"> |
| | | <el-card class="box-card"> |
| | | <template v-slot:header> |
| | | <div class="clearfix"> |
| | | <span>个人信æ¯</span> |
| | | </div> |
| | | </template> |
| | | <div> |
| | | <div class="text-center"> |
| | | <userAvatar /> |
| | | </div> |
| | | <ul class="list-group list-group-striped"> |
| | | <li class="list-group-item"> |
| | | <svg-icon icon-class="user" />ç¨æ·åç§° |
| | | <div class="pull-right">{{ state.user.userName }}</div> |
| | | </li> |
| | | <li class="list-group-item"> |
| | | <svg-icon icon-class="phone" />ææºå·ç |
| | | <div class="pull-right">{{ state.user.phonenumber }}</div> |
| | | </li> |
| | | <li class="list-group-item"> |
| | | <svg-icon icon-class="email" />ç¨æ·é®ç®± |
| | | <div class="pull-right">{{ state.user.email }}</div> |
| | | </li> |
| | | <li class="list-group-item"> |
| | | <svg-icon icon-class="tree" />æå±é¨é¨ |
| | | <div class="pull-right" v-if="state.user.dept">{{ state.user.dept.deptName }} / {{ state.postGroup }}</div> |
| | | </li> |
| | | <li class="list-group-item"> |
| | | <svg-icon icon-class="peoples" />æå±è§è² |
| | | <div class="pull-right">{{ state.roleGroup }}</div> |
| | | </li> |
| | | <li class="list-group-item"> |
| | | <svg-icon icon-class="date" />åå»ºæ¥æ |
| | | <div class="pull-right">{{ state.user.createTime }}</div> |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :span="18" :xs="24"> |
| | | <el-card> |
| | | <template v-slot:header> |
| | | <div class="clearfix"> |
| | | <span>åºæ¬èµæ</span> |
| | | </div> |
| | | </template> |
| | | <el-tabs v-model="activeTab"> |
| | | <el-tab-pane label="åºæ¬èµæ" name="userinfo"> |
| | | <userInfo :user="state.user" /> |
| | | </el-tab-pane> |
| | | <el-tab-pane label="ä¿®æ¹å¯ç " name="resetPwd"> |
| | | <resetPwd /> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="Profile"> |
| | | import userAvatar from "./userAvatar" |
| | | import userInfo from "./userInfo" |
| | | import resetPwd from "./resetPwd" |
| | | import { getUserProfile } from "@/api/system/user" |
| | | |
| | | const activeTab = ref("userinfo") |
| | | const state = reactive({ |
| | | user: {}, |
| | | roleGroup: {}, |
| | | postGroup: {} |
| | | }) |
| | | |
| | | function getUser() { |
| | | getUserProfile().then(response => { |
| | | state.user = response.data |
| | | state.roleGroup = response.roleGroup |
| | | state.postGroup = response.postGroup |
| | | }) |
| | | } |
| | | |
| | | getUser() |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-form ref="pwdRef" :model="user" :rules="rules" label-width="80px"> |
| | | <el-form-item label="æ§å¯ç " prop="oldPassword"> |
| | | <el-input v-model="user.oldPassword" placeholder="请è¾å
¥æ§å¯ç " type="password" show-password /> |
| | | </el-form-item> |
| | | <el-form-item label="æ°å¯ç " prop="newPassword"> |
| | | <el-input v-model="user.newPassword" placeholder="请è¾å
¥æ°å¯ç " type="password" show-password /> |
| | | </el-form-item> |
| | | <el-form-item label="确认å¯ç " prop="confirmPassword"> |
| | | <el-input v-model="user.confirmPassword" placeholder="请确认æ°å¯ç " type="password" show-password/> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" @click="submit">ä¿å</el-button> |
| | | <el-button type="danger" @click="close">å
³é</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { updateUserPwd } from "@/api/system/user" |
| | | |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | const user = reactive({ |
| | | oldPassword: undefined, |
| | | newPassword: undefined, |
| | | confirmPassword: undefined |
| | | }) |
| | | |
| | | const equalToPassword = (rule, value, callback) => { |
| | | if (user.newPassword !== value) { |
| | | callback(new Error("两次è¾å
¥çå¯ç ä¸ä¸è´")) |
| | | } else { |
| | | callback() |
| | | } |
| | | } |
| | | |
| | | const rules = ref({ |
| | | oldPassword: [{ required: true, message: "æ§å¯ç ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | newPassword: [{ required: true, message: "æ°å¯ç ä¸è½ä¸ºç©º", trigger: "blur" }, { min: 6, max: 20, message: "é¿åº¦å¨ 6 å° 20 个å符", trigger: "blur" }, { pattern: /^[^<>"'|\\]+$/, message: "ä¸è½å
å«éæ³å符ï¼< > \" ' \\\ |", trigger: "blur" }], |
| | | confirmPassword: [{ required: true, message: "确认å¯ç ä¸è½ä¸ºç©º", trigger: "blur" }, { required: true, validator: equalToPassword, trigger: "blur" }] |
| | | }) |
| | | |
| | | /** æäº¤æé® */ |
| | | function submit() { |
| | | proxy.$refs.pwdRef.validate(valid => { |
| | | if (valid) { |
| | | updateUserPwd(user.oldPassword, user.newPassword).then(response => { |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æå") |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** å
³éæé® */ |
| | | function close() { |
| | | proxy.$tab.closePage() |
| | | } |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="user-info-head" @click="editCropper()"> |
| | | <img :src="options.img" title="ç¹å»ä¸ä¼ 头å" class="img-circle img-lg" /> |
| | | <el-dialog :title="title" v-model="open" width="800px" append-to-body @opened="modalOpened" @close="closeDialog"> |
| | | <el-row> |
| | | <el-col :xs="24" :md="12" :style="{ height: '350px' }"> |
| | | <vue-cropper ref="cropper" :img="options.img" :info="true" :autoCrop="options.autoCrop" |
| | | :autoCropWidth="options.autoCropWidth" :autoCropHeight="options.autoCropHeight" :fixedBox="options.fixedBox" |
| | | :outputType="options.outputType" @realTime="realTime" v-if="visible" /> |
| | | </el-col> |
| | | <el-col :xs="24" :md="12" :style="{ height: '350px' }"> |
| | | <div class="avatar-upload-preview"> |
| | | <img :src="options.previews.url" :style="options.previews.img" /> |
| | | </div> |
| | | </el-col> |
| | | </el-row> |
| | | <br /> |
| | | <el-row> |
| | | <el-col :lg="2" :md="2"> |
| | | <el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload"> |
| | | <el-button> |
| | | éæ© |
| | | <el-icon class="el-icon--right"> |
| | | <Upload /> |
| | | </el-icon> |
| | | </el-button> |
| | | </el-upload> |
| | | </el-col> |
| | | <el-col :lg="{ span: 1, offset: 2 }" :md="2"> |
| | | <el-button icon="Plus" @click="changeScale(1)"></el-button> |
| | | </el-col> |
| | | <el-col :lg="{ span: 1, offset: 1 }" :md="2"> |
| | | <el-button icon="Minus" @click="changeScale(-1)"></el-button> |
| | | </el-col> |
| | | <el-col :lg="{ span: 1, offset: 1 }" :md="2"> |
| | | <el-button icon="RefreshLeft" @click="rotateLeft()"></el-button> |
| | | </el-col> |
| | | <el-col :lg="{ span: 1, offset: 1 }" :md="2"> |
| | | <el-button icon="RefreshRight" @click="rotateRight()"></el-button> |
| | | </el-col> |
| | | <el-col :lg="{ span: 2, offset: 6 }" :md="2"> |
| | | <el-button type="primary" @click="uploadImg()">æ 交</el-button> |
| | | </el-col> |
| | | </el-row> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import "vue-cropper/dist/index.css" |
| | | import { VueCropper } from "vue-cropper" |
| | | import { uploadAvatar } from "@/api/system/user" |
| | | import useUserStore from "@/store/modules/user" |
| | | |
| | | const userStore = useUserStore() |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | const open = ref(false) |
| | | const visible = ref(false) |
| | | const title = ref("ä¿®æ¹å¤´å") |
| | | |
| | | //å¾çè£åªæ°æ® |
| | | const options = reactive({ |
| | | img: userStore.avatar, // è£åªå¾ççå°å |
| | | autoCrop: true, // æ¯å¦é»è®¤çææªå¾æ¡ |
| | | autoCropWidth: 200, // é»è®¤çææªå¾æ¡å®½åº¦ |
| | | autoCropHeight: 200, // é»è®¤çææªå¾æ¡é«åº¦ |
| | | fixedBox: true, // åºå®æªå¾æ¡å¤§å° ä¸å
许æ¹å |
| | | outputType: "png", // é»è®¤çææªå¾ä¸ºPNGæ ¼å¼ |
| | | filename: 'avatar', // æä»¶åç§° |
| | | previews: {} //é¢è§æ°æ® |
| | | }) |
| | | |
| | | /** ç¼è¾å¤´å */ |
| | | function editCropper() { |
| | | open.value = true |
| | | } |
| | | |
| | | /** æå¼å¼¹åºå±ç»ææ¶çåè° */ |
| | | function modalOpened() { |
| | | visible.value = true |
| | | } |
| | | |
| | | /** è¦çé»è®¤ä¸ä¼ è¡ä¸º */ |
| | | function requestUpload() { } |
| | | |
| | | /** åå·¦æè½¬ */ |
| | | function rotateLeft() { |
| | | proxy.$refs.cropper.rotateLeft() |
| | | } |
| | | |
| | | /** åå³æè½¬ */ |
| | | function rotateRight() { |
| | | proxy.$refs.cropper.rotateRight() |
| | | } |
| | | |
| | | /** å¾çç¼©æ¾ */ |
| | | function changeScale(num) { |
| | | num = num || 1 |
| | | proxy.$refs.cropper.changeScale(num) |
| | | } |
| | | |
| | | /** ä¸ä¼ é¢å¤ç */ |
| | | function beforeUpload(file) { |
| | | if (file.type.indexOf("image/") == -1) { |
| | | proxy.$modal.msgError("æä»¶æ ¼å¼é误ï¼è¯·ä¸ä¼ å¾çç±»å,å¦ï¼JPGï¼PNGåç¼çæä»¶ã") |
| | | } else { |
| | | const reader = new FileReader() |
| | | reader.readAsDataURL(file) |
| | | reader.onload = () => { |
| | | options.img = reader.result |
| | | options.filename = file.name |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** ä¸ä¼ å¾ç */ |
| | | function uploadImg() { |
| | | proxy.$refs.cropper.getCropBlob(data => { |
| | | let formData = new FormData() |
| | | formData.append("avatarfile", data, options.filename) |
| | | uploadAvatar(formData).then(response => { |
| | | open.value = false |
| | | options.img = import.meta.env.VITE_APP_BASE_API + '/profile/' + response.imgUrl |
| | | userStore.avatar = options.img |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æå") |
| | | visible.value = false |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | /** 宿¶é¢è§ */ |
| | | function realTime(data) { |
| | | options.previews = data |
| | | } |
| | | |
| | | /** å
³éçªå£ */ |
| | | function closeDialog() { |
| | | options.img = userStore.avatar |
| | | options.visible = false |
| | | } |
| | | </script> |
| | | |
| | | <style lang='scss' scoped> |
| | | .user-info-head { |
| | | position: relative; |
| | | display: inline-block; |
| | | height: 120px; |
| | | } |
| | | |
| | | .user-info-head:hover:after { |
| | | content: "+"; |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | top: 0; |
| | | bottom: 0; |
| | | color: #eee; |
| | | background: rgba(0, 0, 0, 0.5); |
| | | font-size: 24px; |
| | | font-style: normal; |
| | | -webkit-font-smoothing: antialiased; |
| | | -moz-osx-font-smoothing: grayscale; |
| | | cursor: pointer; |
| | | line-height: 110px; |
| | | border-radius: 50%; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-form ref="userRef" :model="form" :rules="rules" label-width="80px"> |
| | | <el-form-item label="ç¨æ·æµç§°" prop="nickName"> |
| | | <el-input v-model="form.nickName" maxlength="30" /> |
| | | </el-form-item> |
| | | <el-form-item label="ææºå·ç " prop="phonenumber"> |
| | | <el-input v-model="form.phonenumber" maxlength="11" /> |
| | | </el-form-item> |
| | | <el-form-item label="é®ç®±" prop="email"> |
| | | <el-input v-model="form.email" maxlength="50" /> |
| | | </el-form-item> |
| | | <el-form-item label="æ§å«"> |
| | | <el-radio-group v-model="form.sex"> |
| | | <el-radio value="0">ç·</el-radio> |
| | | <el-radio value="1">女</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" @click="submit">ä¿å</el-button> |
| | | <el-button type="danger" @click="close">å
³é</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { updateUserProfile } from "@/api/system/user" |
| | | |
| | | const props = defineProps({ |
| | | user: { |
| | | type: Object |
| | | } |
| | | }) |
| | | |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | const form = ref({}) |
| | | const rules = ref({ |
| | | nickName: [{ required: true, message: "ç¨æ·æµç§°ä¸è½ä¸ºç©º", trigger: "blur" }], |
| | | email: [{ required: true, message: "é®ç®±å°åä¸è½ä¸ºç©º", trigger: "blur" }, { type: "email", message: "请è¾å
¥æ£ç¡®çé®ç®±å°å", trigger: ["blur", "change"] }], |
| | | phonenumber: [{ required: true, message: "ææºå·ç ä¸è½ä¸ºç©º", trigger: "blur" }, { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请è¾å
¥æ£ç¡®çææºå·ç ", trigger: "blur" }], |
| | | }) |
| | | |
| | | /** æäº¤æé® */ |
| | | function submit() { |
| | | proxy.$refs.userRef.validate(valid => { |
| | | if (valid) { |
| | | updateUserProfile(form.value).then(response => { |
| | | proxy.$modal.msgSuccess("ä¿®æ¹æå") |
| | | props.user.phonenumber = form.value.phonenumber |
| | | props.user.email = form.value.email |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** å
³éæé® */ |
| | | function close() { |
| | | proxy.$tab.closePage() |
| | | } |
| | | |
| | | // åæ¾å½åç»å½ç¨æ·ä¿¡æ¯ |
| | | watch(() => props.user, user => { |
| | | if (user) { |
| | | form.value = { nickName: user.nickName, phonenumber: user.phonenumber, email: user.email, sex: user.sex } |
| | | } |
| | | },{ immediate: true }) |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼ç¼åçæ§--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-row :gutter="10"> |
| | | <el-col :span="24" class="card-box"> |
| | | <el-card> |
| | | <template #header><Monitor style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">åºæ¬ä¿¡æ¯</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <table cellspacing="0" style="width: 100%"> |
| | | <tbody> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">Redisçæ¬</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_version }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">è¿è¡æ¨¡å¼</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_mode == "standalone" ? "åæº" : "é群" }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">端å£</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.tcp_port }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">客æ·ç«¯æ°</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.connected_clients }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">è¿è¡æ¶é´(天)</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.uptime_in_days }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">使ç¨å
å</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.used_memory_human }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">使ç¨CPU</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ parseFloat(cache.info.used_cpu_user_children).toFixed(2) }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">å
åé
ç½®</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.maxmemory_human }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">AOFæ¯å¦å¼å¯</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.aof_enabled == "0" ? "å¦" : "æ¯" }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">RDBæ¯å¦æå</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.rdb_last_bgsave_status }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">Keyæ°é</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.dbSize">{{ cache.dbSize }} </div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">ç½ç»å
¥å£/åºå£</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.instantaneous_input_kbps }}kps/{{cache.info.instantaneous_output_kbps}}kps</div></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <el-col :span="12" class="card-box"> |
| | | <el-card> |
| | | <template #header><PieChart style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">å½ä»¤ç»è®¡</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <div ref="commandstats" style="height: 420px" /> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <el-col :span="12" class="card-box"> |
| | | <el-card> |
| | | <template #header><Odometer style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">å
åä¿¡æ¯</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <div ref="usedmemory" style="height: 420px" /> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup name="Cache"> |
| | | import { getCache } from '@/api/monitor/cache' |
| | | import * as echarts from 'echarts' |
| | | |
| | | const cache = ref([]) |
| | | const commandstats = ref(null) |
| | | const usedmemory = ref(null) |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | function getList() { |
| | | proxy.$modal.loading("æ£å¨å è½½ç¼åçæ§æ°æ®ï¼è¯·ç¨åï¼") |
| | | getCache().then(response => { |
| | | proxy.$modal.closeLoading() |
| | | cache.value = response.data |
| | | |
| | | const commandstatsIntance = echarts.init(commandstats.value, "macarons") |
| | | commandstatsIntance.setOption({ |
| | | tooltip: { |
| | | trigger: "item", |
| | | formatter: "{a} <br/>{b} : {c} ({d}%)" |
| | | }, |
| | | series: [ |
| | | { |
| | | name: "å½ä»¤", |
| | | type: "pie", |
| | | roseType: "radius", |
| | | radius: [15, 95], |
| | | center: ["50%", "38%"], |
| | | data: response.data.commandStats, |
| | | animationEasing: "cubicInOut", |
| | | animationDuration: 1000 |
| | | } |
| | | ] |
| | | }) |
| | | const usedmemoryInstance = echarts.init(usedmemory.value, "macarons") |
| | | usedmemoryInstance.setOption({ |
| | | tooltip: { |
| | | formatter: "{b} <br/>{a} : " + cache.value.info.used_memory_human |
| | | }, |
| | | series: [ |
| | | { |
| | | name: "å³°å¼", |
| | | type: "gauge", |
| | | min: 0, |
| | | max: 1000, |
| | | detail: { |
| | | formatter: cache.value.info.used_memory_human |
| | | }, |
| | | data: [ |
| | | { |
| | | value: parseFloat(cache.value.info.used_memory_human), |
| | | name: "å
åæ¶è" |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | }) |
| | | window.addEventListener("resize", () => { |
| | | commandstatsIntance.resize() |
| | | usedmemoryInstance.resize() |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | getList() |
| | | </script> |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼æ°æ®çæ§--> |
| | | <template> |
| | | <div> |
| | | <i-frame v-model:src="url"></i-frame> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import iFrame from '@/components/iFrame' |
| | | |
| | | import { ref } from 'vue' |
| | | |
| | | const url = ref(import.meta.env.VITE_APP_BASE_API + '/druid/login.html') |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--OA模åï¼æå¡å¨çæ§--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <el-row :gutter="10"> |
| | | <el-col :span="12" class="card-box"> |
| | | <el-card> |
| | | <template #header><Cpu style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">CPU</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <table cellspacing="0" style="width: 100%;"> |
| | | <thead> |
| | | <tr> |
| | | <th class="el-table__cell is-leaf"><div class="cell">屿§</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">å¼</div></th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">æ ¸å¿æ°</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.cpuNum }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">ç¨æ·ä½¿ç¨ç</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.used }}%</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">ç³»ç»ä½¿ç¨ç</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.sys }}%</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">å½å空é²ç</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.free }}%</div></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <el-col :span="12" class="card-box"> |
| | | <el-card> |
| | | <template #header><Tickets style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">å
å</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <table cellspacing="0" style="width: 100%;"> |
| | | <thead> |
| | | <tr> |
| | | <th class="el-table__cell is-leaf"><div class="cell">屿§</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">å
å</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">JVM</div></th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">æ»å
å</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem">{{ server.mem.total }}G</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.total }}M</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">å·²ç¨å
å</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem">{{ server.mem.used}}G</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.used}}M</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">å©ä½å
å</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem">{{ server.mem.free }}G</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.free }}M</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">使ç¨ç</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem" :class="{'text-danger': server.mem.usage > 80}">{{ server.mem.usage }}%</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm" :class="{'text-danger': server.jvm.usage > 80}">{{ server.jvm.usage }}%</div></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <el-col :span="24" class="card-box"> |
| | | <el-card> |
| | | <template #header><Monitor style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">æå¡å¨ä¿¡æ¯</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <table cellspacing="0" style="width: 100%;"> |
| | | <tbody> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">æå¡å¨åç§°</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.computerName }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">æä½ç³»ç»</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.osName }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">æå¡å¨IP</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.computerIp }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">ç³»ç»æ¶æ</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.osArch }}</div></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <el-col :span="24" class="card-box"> |
| | | <el-card> |
| | | <template #header><CoffeeCup style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">Javaèææºä¿¡æ¯</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <table cellspacing="0" style="width: 100%;table-layout:fixed;"> |
| | | <tbody> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">Javaåç§°</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.name }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">Javaçæ¬</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.version }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td class="el-table__cell is-leaf"><div class="cell">å¯å¨æ¶é´</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.startTime }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">è¿è¡æ¶é¿</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.runTime }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td colspan="1" class="el-table__cell is-leaf"><div class="cell">å®è£
è·¯å¾</div></td> |
| | | <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.home }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td colspan="1" class="el-table__cell is-leaf"><div class="cell">项ç®è·¯å¾</div></td> |
| | | <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.userDir }}</div></td> |
| | | </tr> |
| | | <tr> |
| | | <td colspan="1" class="el-table__cell is-leaf"><div class="cell">è¿è¡åæ°</div></td> |
| | | <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.inputArgs }}</div></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | |
| | | <el-col :span="24" class="card-box"> |
| | | <el-card> |
| | | <template #header><MessageBox style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">ç£çç¶æ</span></template> |
| | | <div class="el-table el-table--enable-row-hover el-table--medium"> |
| | | <table cellspacing="0" style="width: 100%;"> |
| | | <thead> |
| | | <tr> |
| | | <th class="el-table__cell el-table__cell is-leaf"><div class="cell">ç符路å¾</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">æä»¶ç³»ç»</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">ç符类å</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">æ»å¤§å°</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">å¯ç¨å¤§å°</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">å·²ç¨å¤§å°</div></th> |
| | | <th class="el-table__cell is-leaf"><div class="cell">å·²ç¨ç¾åæ¯</div></th> |
| | | </tr> |
| | | </thead> |
| | | <tbody v-if="server.sysFiles"> |
| | | <tr v-for="(sysFile, index) in server.sysFiles" :key="index"> |
| | | <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.dirName }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.sysTypeName }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.typeName }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.total }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.free }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.used }}</div></td> |
| | | <td class="el-table__cell is-leaf"><div class="cell" :class="{'text-danger': sysFile.usage > 80}">{{ sysFile.usage }}%</div></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { getServer } from '@/api/monitor/server' |
| | | import {onMounted} from "vue"; |
| | | |
| | | const server = ref([]) |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | function getList() { |
| | | proxy.$modal.loading("æ£å¨å è½½æå¡çæ§æ°æ®ï¼è¯·ç¨åï¼") |
| | | getServer().then(response => { |
| | | server.value = response.data |
| | | proxy.$modal.closeLoading() |
| | | }) |
| | | } |
| | | |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | </script> |