| | |
| | | }); |
| | | } |
| | | |
| | | /** å®¡æ ¸ä¸ä¿®æ¹å®¡æ¹å®ä¾ PUT /approvalInstance/update */ |
| | | /** |
| | | * ä¿®æ¹å®¡æ¹å®ä¾ PUT /approvalInstance/update |
| | | * @param {Object} approvalInstanceDto 审æ¹å®ä¾ï¼éå« idï¼å
¶ä½å段æä¸å¡ä¿ç/æ´æ°ï¼ |
| | | */ |
| | | export function updateApprovalInstance(approvalInstanceDto) { |
| | | return request({ |
| | | url: "/approvalInstance/update", |
| | |
| | | data: { approvalInstanceDto }, |
| | | }); |
| | | } |
| | | |
| | | /** 审æ¹ï¼éè¿/驳åï¼POST /approvalInstance/approve */ |
| | | export function approveApprovalInstance(approvalInstanceDto) { |
| | | return request({ |
| | | url: "/approvalInstance/approve", |
| | | method: "post", |
| | | data: { approvalInstanceDto }, |
| | | }); |
| | | } |
| | | |
| | | /** å é¤å®¡æ¹å®ä¾ DELETE /approvalInstance/delete */ |
| | | export function deleteApprovalInstance(ids) { |
| | | const idList = (Array.isArray(ids) ? ids : [ids]).filter( |
| | | id => id != null && id !== "" |
| | | ); |
| | | return request({ |
| | | url: "/approvalInstance/delete", |
| | | method: "delete", |
| | | data: idList, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import request from "@/utils/request"; |
| | | |
| | | /** å页æ¥è¯¢è´¢å¡æ¥é GET /finReimbursement/listPage */ |
| | | export function listFinReimbursementPage(params) { |
| | | return request({ |
| | | url: "/finReimbursement/listPage", |
| | | method: "get", |
| | | params, |
| | | }); |
| | | } |
| | | |
| | | /** 详æ
queryï¼Spring ç»å® finReimbursementDto.idï¼å¿ç¨ finReimbursementDto[id] */ |
| | | function buildFinReimbursementDetailParams(idOrDto) { |
| | | const raw = |
| | | typeof idOrDto === "object" && idOrDto !== null |
| | | ? idOrDto.id ?? idOrDto.reimbursementId |
| | | : idOrDto; |
| | | return { |
| | | "finReimbursementDto.id": raw, |
| | | id: raw, |
| | | }; |
| | | } |
| | | |
| | | /** æ¥è¯¢è´¢å¡æ¥é详æ
GET /finReimbursement/detail */ |
| | | export function getFinReimbursementDetail(idOrDto) { |
| | | return request({ |
| | | url: "/finReimbursement/detail", |
| | | method: "get", |
| | | params: buildFinReimbursementDetailParams(idOrDto), |
| | | }); |
| | | } |
| | | |
| | | /** æ°å¢è´¢å¡æ¥é POST /finReimbursement/save */ |
| | | export function saveFinReimbursement(finReimbursementDto) { |
| | | return request({ |
| | | url: "/finReimbursement/save", |
| | | method: "post", |
| | | data: finReimbursementDto, |
| | | }); |
| | | } |
| | | |
| | | /** ä¿®æ¹è´¢å¡æ¥é POST /finReimbursement/update */ |
| | | export function updateFinReimbursement(finReimbursementDto) { |
| | | return request({ |
| | | url: "/finReimbursement/update", |
| | | method: "post", |
| | | data: finReimbursementDto, |
| | | }); |
| | | } |
| | | |
| | | /** å é¤è´¢å¡æ¥é DELETE /finReimbursement/deleteï¼body 为 ID æ°ç»ï¼ */ |
| | | export function deleteFinReimbursement(ids) { |
| | | const idList = (Array.isArray(ids) ? ids : [ids]).filter( |
| | | (id) => id != null && id !== "" |
| | | ); |
| | | return request({ |
| | | url: "/finReimbursement/delete", |
| | | method: "delete", |
| | | data: idList, |
| | | }); |
| | | } |
| | | |
| | | /** æ°å¢èµ° saveï¼ä¿®æ¹èµ° updateï¼ä¸æ¥å£ææ¡£ä¸è´ï¼ */ |
| | | export function persistFinReimbursement(finReimbursementDto, isEdit = false) { |
| | | if (isEdit) { |
| | | return updateFinReimbursement(finReimbursementDto); |
| | | } |
| | | const payload = { ...finReimbursementDto }; |
| | | delete payload.id; |
| | | return saveFinReimbursement(payload); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import request from "@/utils/request"; |
| | | |
| | | /** å²ä½ä¸æ GET /system/post/optionselect */ |
| | | export function findPostOptions(query) { |
| | | return request({ |
| | | url: "/system/post/optionselect", |
| | | method: "get", |
| | | params: query, |
| | | }); |
| | | } |
| | |
| | | /** æ¥é管ç */ |
| | | travelReimburse: `/${P}/ReimburseManage/travel-reimburse/index`, |
| | | costReimburse: `/${P}/ReimburseManage/cost-reimburse/index`, |
| | | reimburseDetail: `/${P}/ReimburseManage/reimburse-detail/index`, |
| | | reimburseForm: `/${P}/ReimburseManage/reimburse-form/index`, |
| | | /** åå管ç */ |
| | | purchaseContract: `/${P}/ContractManage/purchase-contract/index`, |
| | | saleContract: `/${P}/ContractManage/sale-contract/index`, |
| | |
| | | approveList: `/${P}/ApproveManage/approve-list/index`, |
| | | approveListTemplateSelect: `/${P}/ApproveManage/approve-list/template-select`, |
| | | approveListApply: `/${P}/ApproveManage/approve-list/apply`, |
| | | approveListDetail: `/${P}/ApproveManage/approve-list/detail`, |
| | | approveListApprove: `/${P}/ApproveManage/approve-list/approve`, |
| | | approveTemplate: `/${P}/ApproveManage/approve-template/index`, |
| | | approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`, |
| | | approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`, |
| | |
| | | key: "HrManage", |
| | | name: "人äºç®¡ç", |
| | | children: [ |
| | | { label: "å工档æ¡", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.staffArchive }, |
| | | { label: "åå·¥åå", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract }, |
| | | // { label: "å工档æ¡", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.staffArchive }, |
| | | // { label: "åå·¥åå", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract }, |
| | | { label: "转æ£ç³è¯·", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.regularApply }, |
| | | { label: "è°å²ç³è¯·", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.transferApply }, |
| | | { label: "离èç³è¯·", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply }, |
| | | // { label: "离èç³è¯·", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply }, |
| | | { label: "å·¥ä½äº¤æ¥", icon: "/static/images/icon/gongchuguanli.svg", path: OA_NAV.workHandover }, |
| | | { label: "å²ä½ç®¡ç", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage }, |
| | | // { label: "å²ä½ç®¡ç", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage }, |
| | | ], |
| | | }, |
| | | { |
| | |
| | | { label: "è´¹ç¨æ¥é", icon: "/static/images/icon/baoxiaoguanli.svg", path: OA_NAV.costReimburse }, |
| | | ], |
| | | }, |
| | | { |
| | | key: "ContractManage", |
| | | name: "åå管ç", |
| | | children: [ |
| | | { label: "éè´åå", icon: "/static/images/icon/caigoutaizhang.svg", path: OA_NAV.purchaseContract }, |
| | | { label: "éå®åå", icon: "/static/images/icon/xiaoshoutaizhang.svg", path: OA_NAV.saleContract }, |
| | | ], |
| | | }, |
| | | // { |
| | | // key: "ContractManage", |
| | | // name: "åå管ç", |
| | | // children: [ |
| | | // { label: "éè´åå", icon: "/static/images/icon/caigoutaizhang.svg", path: OA_NAV.purchaseContract }, |
| | | // { label: "éå®åå", icon: "/static/images/icon/xiaoshoutaizhang.svg", path: OA_NAV.saleContract }, |
| | | // ], |
| | | // }, |
| | | { |
| | | key: "ApproveManage", |
| | | name: "审æ¹ç®¡ç", |
| | |
| | | { label: "å®¡æ¹æ¨¡æ¿", icon: "/static/images/icon/guizhangzhidu.svg", path: OA_NAV.approveTemplate }, |
| | | ], |
| | | }, |
| | | { |
| | | key: "EnterpriseNews", |
| | | name: "ä¼ä¸æ°é»", |
| | | children: [ |
| | | { label: "ä¼ä¸æ°é»", icon: "/static/images/icon/zhishiku.svg", path: OA_NAV.enterpriseNews }, |
| | | ], |
| | | }, |
| | | { |
| | | key: "NoticeAnnouncement", |
| | | name: "å
¬åéç¥", |
| | | children: [ |
| | | { label: "å
¬åéç¥", icon: "/static/images/icon/tongzhigonggao.svg", path: OA_NAV.noticeAnnouncement }, |
| | | ], |
| | | }, |
| | | // { |
| | | // key: "EnterpriseNews", |
| | | // name: "ä¼ä¸æ°é»", |
| | | // children: [ |
| | | // { label: "ä¼ä¸æ°é»", icon: "/static/images/icon/zhishiku.svg", path: OA_NAV.enterpriseNews }, |
| | | // ], |
| | | // }, |
| | | // { |
| | | // key: "NoticeAnnouncement", |
| | | // name: "å
¬åéç¥", |
| | | // children: [ |
| | | // { label: "å
¬åéç¥", icon: "/static/images/icon/tongzhigonggao.svg", path: OA_NAV.noticeAnnouncement }, |
| | | // ], |
| | | // }, |
| | | ]; |
| | | |
| | | /** å·¥ä½å°æå¹³èåï¼çº¯å端é
ç½®ï¼ */ |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ReimburseManage/reimburse-detail/index", |
| | | "style": { |
| | | "navigationBarTitleText": "æ¥é详æ
", |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ReimburseManage/reimburse-form/index", |
| | | "style": { |
| | | "navigationBarTitleText": "æ¥éå¡«æ¥", |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ContractManage/purchase-contract/index", |
| | | "style": { |
| | | "navigationBarTitleText": "éè´åå", |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ApproveManage/approve-list/detail", |
| | | "style": { |
| | | "navigationBarTitleText": "审æ¹è¯¦æ
", |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ApproveManage/approve-list/approve", |
| | | "style": { |
| | | "navigationBarTitleText": "审æ¹å¤ç", |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ApproveManage/approve-template/index", |
| | | "style": { |
| | | "navigationBarTitleText": "å®¡æ¹æ¨¡æ¿", |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | 审æ¹å®ä¾è¯¦æ
å±ç¤ºï¼åºæ¬ä¿¡æ¯ + å¡«æ¥ + æµç¨ + 审æ¹è®°å½ |
| | | --> |
| | | <template> |
| | | <view class="detail-body"> |
| | | <view class="section-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">åºæ¬ä¿¡æ¯</text> |
| | | </view> |
| | | <view class="info-rows"> |
| | | <view class="info-row"> |
| | | <text class="info-label">ä¸å¡åå·</text> |
| | | <text class="info-value">{{ row.instanceNo || row.id || "â" }}</text> |
| | | </view> |
| | | <view class="info-row"> |
| | | <text class="info-label">审æ¹ç¶æ</text> |
| | | <u-tag :type="statusTagType(row.status)" |
| | | :text="statusLabel(row.status)" |
| | | size="mini" /> |
| | | </view> |
| | | <view class="info-row"> |
| | | <text class="info-label">模æ¿åç§°</text> |
| | | <text class="info-value">{{ row.templateName || "â" }}</text> |
| | | </view> |
| | | <view class="info-row"> |
| | | <text class="info-label">ä¸å¡åç§°</text> |
| | | <text class="info-value">{{ row.businessName || "â" }}</text> |
| | | </view> |
| | | <view class="info-row"> |
| | | <text class="info-label">ç³è¯·äºº</text> |
| | | <text class="info-value">{{ row.applicantName || "â" }}</text> |
| | | </view> |
| | | <view class="info-row"> |
| | | <text class="info-label">ç³è¯·æ é¢</text> |
| | | <text class="info-value">{{ row.title || "â" }}</text> |
| | | </view> |
| | | <view v-if="rejectReason" |
| | | class="info-row"> |
| | | <text class="info-label">驳ååå </text> |
| | | <text class="info-value reject-text">{{ rejectReason }}</text> |
| | | </view> |
| | | <view class="info-row"> |
| | | <text class="info-label">ç³è¯·æ¶é´</text> |
| | | <text class="info-value">{{ formatDateTime(row.applyTime || row.createTime) }}</text> |
| | | </view> |
| | | <view v-if="row.finishTime" |
| | | class="info-row"> |
| | | <text class="info-label">宿æ¶é´</text> |
| | | <text class="info-value">{{ formatDateTime(row.finishTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="section-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">å¡«æ¥å
容</text> |
| | | </view> |
| | | <view v-if="displayFields.length" |
| | | class="info-rows"> |
| | | <view v-for="field in displayFields" |
| | | :key="field.key" |
| | | class="info-row"> |
| | | <text class="info-label">{{ field.label }}</text> |
| | | <text class="info-value">{{ displayFieldValue(field) }}</text> |
| | | </view> |
| | | <view v-for="(extra, idx) in moduleExtraRows" |
| | | :key="`extra-${idx}`" |
| | | class="info-row"> |
| | | <text class="info-label">{{ extra.label }}</text> |
| | | <text class="info-value">{{ extra.value }}</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="empty-hint">ææ å¡«æ¥å
容</view> |
| | | </view> |
| | | |
| | | <view class="section-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">å®¡æ¹æµç¨ï¼{{ flowNodes.length }} 项ï¼</text> |
| | | </view> |
| | | <view v-if="flowNodes.length" |
| | | class="flow-wrap"> |
| | | <view v-for="(node, nodeIndex) in flowNodes" |
| | | :key="nodeIndex" |
| | | class="flow-node-block"> |
| | | <view class="flow-node-card"> |
| | | <view class="node-header"> |
| | | <view class="node-level-badge">{{ node.levelNo }}</view> |
| | | <text class="node-level-text">第{{ levelLabel(node.levelNo) }}级</text> |
| | | <u-tag size="mini" |
| | | :type="node.approveType === 'OR' ? 'warning' : 'primary'" |
| | | :text="node.approveType === 'OR' ? 'æç¾' : 'ä¼ç¾'" |
| | | plain /> |
| | | </view> |
| | | <view class="approver-list"> |
| | | <view v-for="(a, aIdx) in node.approvers" |
| | | :key="aIdx" |
| | | class="approver-row"> |
| | | <text class="approver-name">{{ a.approverName }}</text> |
| | | <u-tag v-if="a.taskStatus" |
| | | size="mini" |
| | | :type="taskStatusTagType(a.taskStatus)" |
| | | :text="taskStatusText(a.taskStatus)" |
| | | plain /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-if="nodeIndex < flowNodes.length - 1" |
| | | class="flow-connector-line" /> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="empty-hint">ææ æµç¨èç¹</view> |
| | | </view> |
| | | |
| | | <view class="section-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">审æ¹è®°å½</text> |
| | | </view> |
| | | <view v-if="approvalRecords.length" |
| | | class="record-list"> |
| | | <view v-for="(rec, index) in approvalRecords" |
| | | :key="rec.id ?? index" |
| | | class="record-item"> |
| | | <view class="record-head"> |
| | | <text class="record-operator">{{ rec.operatorName }}</text> |
| | | <u-tag size="mini" |
| | | :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'error' : 'info'" |
| | | :text="recordActionLabel(rec.result)" |
| | | plain /> |
| | | </view> |
| | | <text class="record-time">{{ rec.time }}</text> |
| | | <text class="record-opinion">{{ rec.opinion || "æ æè§" }}</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="empty-hint">ææ å®¡æ¹è®°å½</view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | computeLeaveDurationDisplay, |
| | | computeOvertimeHoursDisplay, |
| | | } from "../../../_utils/approvalModuleApplyExtras.js"; |
| | | import { resolveInstanceFormPayload } from "../../../_utils/approvalModuleListSearch.js"; |
| | | import { |
| | | businessStatusTagType, |
| | | businessStatusText, |
| | | displayFieldValue, |
| | | formatDateTime, |
| | | getRejectReasonFromRecords, |
| | | instanceStatusTagType, |
| | | instanceStatusText, |
| | | mapApprovalRecords, |
| | | mapTasksToFlowNodes, |
| | | recordActionLabel, |
| | | resolveInstanceDisplayFields, |
| | | taskStatusTagType, |
| | | taskStatusText, |
| | | } from "../../../_utils/approveListUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | row: { type: Object, default: () => ({}) }, |
| | | moduleKey: { type: String, default: "" }, |
| | | }); |
| | | |
| | | const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å
", "ä¸", "å
«", "ä¹", "å"]; |
| | | |
| | | const isBusinessModule = computed(() => |
| | | [ |
| | | APPROVAL_MODULE_KEYS.LEAVE, |
| | | APPROVAL_MODULE_KEYS.OVERTIME, |
| | | APPROVAL_MODULE_KEYS.TRANSFER, |
| | | APPROVAL_MODULE_KEYS.REGULAR, |
| | | APPROVAL_MODULE_KEYS.WORK_HANDOVER, |
| | | ].includes(props.moduleKey) |
| | | ); |
| | | |
| | | const statusLabel = status => |
| | | isBusinessModule.value ? businessStatusText(status) : instanceStatusText(status); |
| | | |
| | | const statusTagType = status => |
| | | isBusinessModule.value ? businessStatusTagType(status) : instanceStatusTagType(status); |
| | | |
| | | const displayFields = computed(() => resolveInstanceDisplayFields(props.row)); |
| | | |
| | | const moduleExtraRows = computed(() => { |
| | | const rows = []; |
| | | const { fields, formPayload } = resolveInstanceFormPayload(props.row); |
| | | const payload = { ...formPayload }; |
| | | (fields || []).forEach(f => { |
| | | if (f?.key && payload[f.key] == null) payload[f.key] = f.value ?? ""; |
| | | }); |
| | | if (props.moduleKey === APPROVAL_MODULE_KEYS.LEAVE) { |
| | | const balance = payload.leaveBalanceDays; |
| | | if (balance != null && balance !== "") { |
| | | rows.push({ label: "åæä½é¢", value: `${balance} 天` }); |
| | | } |
| | | const days = computeLeaveDurationDisplay(fields, payload); |
| | | if (days) rows.push({ label: "è¯·åæ¶é¿", value: `${days} 天` }); |
| | | } |
| | | if (props.moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) { |
| | | const hours = computeOvertimeHoursDisplay(fields, payload); |
| | | if (hours) rows.push({ label: "å çæ¶é¿", value: `${hours} å°æ¶` }); |
| | | } |
| | | if (props.moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) { |
| | | const post = payload.originalPostName || payload.originalPost; |
| | | if (post) rows.push({ label: "åå²ä½", value: post }); |
| | | } |
| | | return rows; |
| | | }); |
| | | |
| | | const flowNodes = computed(() => mapTasksToFlowNodes(props.row?.tasks)); |
| | | |
| | | const approvalRecords = computed(() => |
| | | mapApprovalRecords(props.row?.records) |
| | | ); |
| | | |
| | | const rejectReason = computed(() => |
| | | getRejectReasonFromRecords(props.row?.records) |
| | | ); |
| | | |
| | | const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | $primary: #2979ff; |
| | | $text: #1f2d3d; |
| | | $text-muted: #909399; |
| | | |
| | | .detail-body { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .section-card { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05); |
| | | } |
| | | |
| | | .section-head { |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f2f4f7; |
| | | } |
| | | |
| | | .section-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | padding-left: 10px; |
| | | border-left: 3px solid $primary; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .info-rows { |
| | | padding: 4px 16px 12px; |
| | | } |
| | | |
| | | .info-row { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | padding: 10px 0; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .info-label { |
| | | flex-shrink: 0; |
| | | font-size: 14px; |
| | | color: $text-muted; |
| | | min-width: 72px; |
| | | } |
| | | |
| | | .info-value { |
| | | flex: 1; |
| | | font-size: 14px; |
| | | color: $text; |
| | | text-align: right; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .reject-text { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .flow-wrap { |
| | | padding: 10px 16px 14px; |
| | | } |
| | | |
| | | .flow-node-block { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | |
| | | .flow-node-card { |
| | | background: #fafbfd; |
| | | border: 1px solid #e8eef5; |
| | | border-radius: 10px; |
| | | padding: 12px; |
| | | } |
| | | |
| | | .node-header { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .node-level-badge { |
| | | width: 26px; |
| | | height: 26px; |
| | | border-radius: 8px; |
| | | background: $primary; |
| | | color: #fff; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .node-level-text { |
| | | flex: 1; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | } |
| | | |
| | | .approver-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .approver-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .approver-name { |
| | | font-size: 13px; |
| | | color: #606266; |
| | | } |
| | | |
| | | .flow-connector-line { |
| | | width: 2px; |
| | | height: 12px; |
| | | background: #d0dff0; |
| | | margin: 4px auto; |
| | | } |
| | | |
| | | .record-list { |
| | | padding: 8px 16px 14px; |
| | | } |
| | | |
| | | .record-item { |
| | | padding: 10px 0; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .record-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .record-operator { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | } |
| | | |
| | | .record-time { |
| | | display: block; |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | } |
| | | |
| | | .record-opinion { |
| | | display: block; |
| | | margin-top: 6px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .empty-hint { |
| | | padding: 12px 16px 16px; |
| | | font-size: 13px; |
| | | color: $text-muted; |
| | | } |
| | | </style> |
| | |
| | | <text class="loading-text">å è½½ä¸...</text> |
| | | </view> |
| | | <template v-else-if="detail"> |
| | | <view class="section"> |
| | | <view class="section-title">åºæ¬ä¿¡æ¯</view> |
| | | <view class="form-body"> |
| | | <view class="form-row"> |
| | | <text class="form-label required">å®¡æ¹æ é¢</text> |
| | | <up-form :model="form" |
| | | label-width="100" |
| | | input-align="right"> |
| | | <u-cell-group title="åºæ¬ä¿¡æ¯" |
| | | class="form-section"> |
| | | <up-form-item label="å®¡æ¹æ é¢" |
| | | required |
| | | class="form-item-name"> |
| | | <up-input v-model="form.title" |
| | | class="name-input-inline" |
| | | placeholder="请è¾å
¥å®¡æ¹æ é¢" |
| | | maxlength="100" |
| | | clearable /> |
| | | </view> |
| | | <view class="form-row"> |
| | | <text class="form-label">å®¡æ¹æ¨¡æ¿</text> |
| | | <text class="form-readonly">{{ templateName }}</text> |
| | | </view> |
| | | <view class="form-row"> |
| | | <text class="form-label">ç³è¯·äºº</text> |
| | | <text class="form-readonly">{{ displayApplicantName }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </up-form-item> |
| | | <up-form-item label="å®¡æ¹æ¨¡æ¿" |
| | | class="form-item-readonly"> |
| | | <up-input :model-value="templateName" |
| | | readonly /> |
| | | </up-form-item> |
| | | <up-form-item label="ç³è¯·äºº" |
| | | class="form-item-readonly"> |
| | | <up-input :model-value="displayApplicantName" |
| | | readonly /> |
| | | </up-form-item> |
| | | </u-cell-group> |
| | | </up-form> |
| | | |
| | | <view class="section"> |
| | | <view class="section-title">å¡«æ¥å
容</view> |
| | | <view class="section-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">å¡«æ¥å
容</text> |
| | | </view> |
| | | <view v-if="formConfigData.prompt" |
| | | class="form-prompt"> |
| | | {{ formConfigData.prompt }} |
| | | </view> |
| | | <view v-if="formConfigData.fields.length" |
| | | class="form-body"> |
| | | <view v-for="field in formConfigData.fields" |
| | | :key="field.key" |
| | | class="form-row form-row--field"> |
| | | <text class="form-label" |
| | | :class="{ required: field.required }">{{ field.label }}</text> |
| | | <up-textarea v-if="field.type === 'textarea'" |
| | | <up-form v-if="formConfigData.fields.length" |
| | | :model="formValues" |
| | | label-width="100" |
| | | input-align="right" |
| | | class="dynamic-form"> |
| | | <up-form-item v-for="field in displayTemplateFields" |
| | | :key="field.key" |
| | | :label="field.label" |
| | | :required="!!field.required" |
| | | :label-position="formItemLabelPosition(field)" |
| | | :class="formItemClass(field)"> |
| | | <up-textarea v-if="isTextareaField(field)" |
| | | v-model="formValues[field.key]" |
| | | :placeholder="`请è¾å
¥${field.label}`" |
| | | maxlength="500" |
| | | border="surround" |
| | | height="80" /> |
| | | <view v-else-if="field.type === 'date'" |
| | | class="date-trigger" |
| | | @click="openDatePicker(field.key)"> |
| | | <up-input :model-value="formValues[field.key]" |
| | | <view v-else-if="isDatetimerangeField(field)" |
| | | class="daterange-fill"> |
| | | <view class="range-fill-row" |
| | | @click="openRangePicker(field, 'start')"> |
| | | <text class="range-fill-label">å¼å§</text> |
| | | <up-input :model-value="getRangePartDisplay(field, 'start')" |
| | | placeholder="å¼å§æ¶é´" |
| | | readonly /> |
| | | <up-icon name="calendar" |
| | | size="16" |
| | | color="#909399" /> |
| | | </view> |
| | | <text class="range-fill-sep">è³</text> |
| | | <view class="range-fill-row" |
| | | @click="openRangePicker(field, 'end')"> |
| | | <text class="range-fill-label">ç»æ</text> |
| | | <up-input :model-value="getRangePartDisplay(field, 'end')" |
| | | placeholder="ç»ææ¶é´" |
| | | readonly /> |
| | | <up-icon name="calendar" |
| | | size="16" |
| | | color="#909399" /> |
| | | </view> |
| | | </view> |
| | | <view v-else-if="isDateLikeField(field)" |
| | | class="field-trigger" |
| | | @click="openDatePicker(field)"> |
| | | <up-input :model-value="formatFieldDisplayValue(field, formValues[field.key])" |
| | | :placeholder="`è¯·éæ©${field.label}`" |
| | | readonly /> |
| | | <up-icon :name="getDatePickerMode(field) === 'time' ? 'clock' : 'calendar'" |
| | | size="18" |
| | | color="#909399" /> |
| | | </view> |
| | | <view v-else-if="isSelectField(field)" |
| | | class="field-trigger" |
| | | @click="openSelectPicker(field)"> |
| | | <up-input :model-value="getSelectDisplayText(field)" |
| | | :placeholder="`è¯·éæ©${field.label}`" |
| | | readonly /> |
| | | <up-icon name="arrow-right" |
| | | size="16" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | <up-input v-else |
| | | v-model="formValues[field.key]" |
| | | :type="field.type === 'number' ? 'digit' : 'text'" |
| | | :type="isNumberField(field) ? 'digit' : 'text'" |
| | | :placeholder="`请è¾å
¥${field.label}`" |
| | | clearable /> |
| | | </view> |
| | | </view> |
| | | </up-form-item> |
| | | </up-form> |
| | | <view v-else |
| | | class="empty-hint">è¯¥æ¨¡æ¿ææ å¡«æ¥é¡¹</view> |
| | | |
| | | <!-- 请åï¼åæä½é¢ + æ¶é¿èªå¨è®¡ç® --> |
| | | <view v-if="isLeaveModule" |
| | | class="module-extra-block"> |
| | | <up-form :model="extraForm" |
| | | label-width="100" |
| | | input-align="right" |
| | | class="dynamic-form"> |
| | | <up-form-item label="åæä½é¢" |
| | | required |
| | | class="form-item-inline"> |
| | | <up-input v-model="extraForm.leaveBalanceDays" |
| | | type="digit" |
| | | placeholder="请è¾å
¥å¤©æ°" |
| | | clearable /> |
| | | </up-form-item> |
| | | <up-form-item label="è¯·åæ¶é¿" |
| | | class="form-item-inline"> |
| | | <view class="readonly-with-unit"> |
| | | <up-input :model-value="leaveDurationText" |
| | | readonly |
| | | placeholder="æ ¹æ®è¯·åæ¶é´èªå¨è®¡ç®" /> |
| | | <text class="unit-text">天</text> |
| | | </view> |
| | | </up-form-item> |
| | | </up-form> |
| | | </view> |
| | | |
| | | <!-- å çï¼æ¶é¿èªå¨è®¡ç® --> |
| | | <view v-if="isOvertimeModule" |
| | | class="module-extra-block"> |
| | | <up-form label-width="100" |
| | | input-align="right" |
| | | class="dynamic-form"> |
| | | <up-form-item label="å çæ¶é¿" |
| | | class="form-item-inline"> |
| | | <view class="readonly-with-unit"> |
| | | <up-input :model-value="overtimeHoursText" |
| | | readonly |
| | | placeholder="æ ¹æ®å çæ¶é´èªå¨è®¡ç®" /> |
| | | <text class="unit-text">å°æ¶</text> |
| | | </view> |
| | | </up-form-item> |
| | | </up-form> |
| | | </view> |
| | | |
| | | <!-- è°å²ï¼åå²ä½èªå¨å¸¦åº --> |
| | | <view v-if="isTransferModule" |
| | | class="module-extra-block"> |
| | | <up-form label-width="100" |
| | | input-align="right" |
| | | class="dynamic-form"> |
| | | <up-form-item label="åå²ä½" |
| | | class="form-item-readonly"> |
| | | <up-input :model-value="extraForm.originalPostName" |
| | | readonly |
| | | placeholder="éæ©ç³è¯·äººåèªå¨å¸¦åº" /> |
| | | </up-form-item> |
| | | </up-form> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="section"> |
| | | <view class="section-title">å®¡æ¹æµç¨</view> |
| | | <view class="section-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">å®¡æ¹æµç¨</text> |
| | | </view> |
| | | <view v-if="detail.nodes?.length" |
| | | class="flow-list"> |
| | | <view v-for="(node, index) in detail.nodes" |
| | | :key="node.id || index" |
| | | class="flow-card"> |
| | | <view class="flow-card-head"> |
| | | <text class="flow-level">第{{ levelLabel(node.levelNo || index + 1) }}级</text> |
| | | <text class="flow-type">{{ approveTypeText(node.approveType) }}</text> |
| | | class="flow-wrap"> |
| | | <view v-for="(node, nodeIndex) in detail.nodes" |
| | | :key="node.id || nodeIndex" |
| | | class="flow-node-block"> |
| | | <view class="flow-node-card"> |
| | | <view class="node-header"> |
| | | <view class="node-level-badge">{{ node.levelNo || nodeIndex + 1 }}</view> |
| | | <text class="node-level-text">第{{ levelLabel(node.levelNo || nodeIndex + 1) }}级</text> |
| | | </view> |
| | | <view class="approve-type-row approve-type-row--readonly"> |
| | | <view class="type-btn" |
| | | :class="{ active: node.approveType !== 'OR' }"> |
| | | ä¼ç¾ |
| | | </view> |
| | | <view class="type-btn" |
| | | :class="{ active: node.approveType === 'OR' }"> |
| | | æç¾ |
| | | </view> |
| | | </view> |
| | | <view class="approver-list"> |
| | | <view v-for="(approver, aIdx) in node.approvers || []" |
| | | :key="approver.id || aIdx" |
| | | class="approver-chip"> |
| | | <view class="approver-avatar">{{ (approver.approverName || "?").charAt(0) }}</view> |
| | | <text class="approver-name">{{ approver.approverName || "-" }}</text> |
| | | </view> |
| | | <text v-if="!(node.approvers || []).length" |
| | | class="empty-hint inline">ææ å®¡æ¹äºº</text> |
| | | </view> |
| | | </view> |
| | | <view class="approver-tags"> |
| | | <text v-for="(approver, aIdx) in node.approvers || []" |
| | | :key="approver.id || aIdx" |
| | | class="approver-tag"> |
| | | {{ approver.approverName || "-" }} |
| | | </text> |
| | | <text v-if="!(node.approvers || []).length" |
| | | class="empty-hint inline">ææ å®¡æ¹äºº</text> |
| | | <view v-if="nodeIndex < detail.nodes.length - 1" |
| | | class="flow-connector"> |
| | | <view class="flow-connector-line" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | @close="showDatePicker = false"> |
| | | <up-datetime-picker :show="true" |
| | | v-model="datePickerTs" |
| | | mode="date" |
| | | :mode="datePickerMode" |
| | | @confirm="onDateConfirm" |
| | | @cancel="showDatePicker = false" /> |
| | | @cancel="onDatePickerCancel" /> |
| | | </up-popup> |
| | | |
| | | <up-action-sheet :show="showSelectSheet" |
| | | :title="selectSheetTitle" |
| | | :actions="selectSheetActions" |
| | | @select="onSelectOption" |
| | | @close="showSelectSheet = false" /> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import FooterButtons from "@/components/FooterButtons.vue"; |
| | |
| | | updateApprovalInstance, |
| | | } from "@/api/oa/approvalInstance.js"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { formatDateToYMD, parseTime } from "@/utils/ruoyi"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { getDept } from "@/api/collaborativeApproval/approvalProcess.js"; |
| | | import { findPostOptions } from "@/api/system/post.js"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | computeLeaveDurationDisplay, |
| | | computeOvertimeHoursDisplay, |
| | | displayTemplateFieldsByModule, |
| | | findApplicantTemplateField, |
| | | findLeaveTimeTemplateField, |
| | | findOvertimeTimeTemplateField, |
| | | inferModuleKeyFromRow, |
| | | loadModuleExtrasFromRow, |
| | | resolveOriginalPostName, |
| | | syncModuleExtrasToFormValues, |
| | | unwrapUserArray, |
| | | userById, |
| | | validateModuleExtras, |
| | | buildPostIdToNameMap, |
| | | } from "../../_utils/approvalModuleApplyExtras.js"; |
| | | import { |
| | | formatDatetimerangeDisplay, |
| | | formatFieldDateValue, |
| | | formatFieldDisplayValue, |
| | | getDatePickerMode, |
| | | getFieldInitialValue, |
| | | getFieldOptionLabel, |
| | | isDatetimerangeField, |
| | | isDateLikeField, |
| | | isNumberField, |
| | | isSelectField, |
| | | isTextareaField, |
| | | joinDatetimerangeValue, |
| | | mergeFormConfigForEdit, |
| | | parseDatetimerangeValue, |
| | | resolveFieldOptions, |
| | | parseApprovalFormConfig, |
| | | parseFieldDateToTs, |
| | | } from "../../_utils/approvalFormField.js"; |
| | | |
| | | const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row"; |
| | | import { EDIT_STORAGE_KEY } from "../../_utils/approveListUtils.js"; |
| | | |
| | | const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å
", "ä¸", "å
«", "ä¹", "å"]; |
| | | |
| | | const userStore = useUserStore(); |
| | | const moduleKey = ref(""); |
| | | const templateId = ref(""); |
| | | const instanceId = ref(""); |
| | | const instanceRow = ref(null); |
| | |
| | | const submitting = ref(false); |
| | | const formValues = reactive({}); |
| | | const form = reactive({ title: "" }); |
| | | const extraForm = reactive({ |
| | | leaveBalanceDays: undefined, |
| | | originalPostName: "", |
| | | }); |
| | | const postIdToName = ref({}); |
| | | const transferUserPool = ref([]); |
| | | |
| | | const isLeaveModule = computed( |
| | | () => moduleKey.value === APPROVAL_MODULE_KEYS.LEAVE |
| | | ); |
| | | const isOvertimeModule = computed( |
| | | () => moduleKey.value === APPROVAL_MODULE_KEYS.OVERTIME |
| | | ); |
| | | const isTransferModule = computed( |
| | | () => moduleKey.value === APPROVAL_MODULE_KEYS.TRANSFER |
| | | ); |
| | | |
| | | const showDatePicker = ref(false); |
| | | const datePickerTs = ref(Date.now()); |
| | | const activeDateFieldKey = ref(""); |
| | | const activeDateField = ref(null); |
| | | const activeRangePart = ref("start"); |
| | | |
| | | const datePickerMode = computed(() => { |
| | | const field = activeDateField.value; |
| | | if (!field) return "date"; |
| | | if (isDatetimerangeField(field)) return "datetime"; |
| | | return getDatePickerMode(field); |
| | | }); |
| | | |
| | | const showSelectSheet = ref(false); |
| | | const activeSelectField = ref(null); |
| | | const pickerUserList = ref([]); |
| | | const pickerDeptList = ref([]); |
| | | |
| | | const isEditMode = computed(() => !!instanceId.value); |
| | | |
| | | const pageTitle = computed(() => (isEditMode.value ? "ç¼è¾å®¡æ¹" : "å起审æ¹")); |
| | | const confirmText = computed(() => (isEditMode.value ? "ä¿å" : "æäº¤å®¡æ¹")); |
| | | const pageTitle = computed(() => (isEditMode.value ? "ä¿®æ¹å®¡æ¹" : "å起审æ¹")); |
| | | const confirmText = computed(() => (isEditMode.value ? "ä¿åä¿®æ¹" : "æäº¤å®¡æ¹")); |
| | | |
| | | const applicantName = computed( |
| | | () => userStore.nickName || userStore.name || "-" |
| | |
| | | () => detail.value?.templateName || instanceRow.value?.templateName || "-" |
| | | ); |
| | | |
| | | const parseFormConfig = raw => { |
| | | if (!raw) return { prompt: "", fields: [] }; |
| | | try { |
| | | const obj = typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | return { |
| | | prompt: obj?.prompt || "", |
| | | fields: Array.isArray(obj?.fields) ? obj.fields : [], |
| | | }; |
| | | } catch { |
| | | return { prompt: "", fields: [] }; |
| | | } |
| | | }; |
| | | |
| | | const formConfigData = computed(() => { |
| | | const raw = isEditMode.value |
| | | ? instanceRow.value?.formConfig |
| | | : detail.value?.formConfig; |
| | | return parseFormConfig(raw); |
| | | if (isEditMode.value) { |
| | | return mergeFormConfigForEdit( |
| | | detail.value?.formConfig, |
| | | instanceRow.value?.formConfig |
| | | ); |
| | | } |
| | | return parseApprovalFormConfig(detail.value?.formConfig); |
| | | }); |
| | | |
| | | const displayTemplateFields = computed(() => |
| | | displayTemplateFieldsByModule(moduleKey.value, formConfigData.value.fields) |
| | | ); |
| | | |
| | | const leaveDurationText = computed(() => { |
| | | if (!isLeaveModule.value) return ""; |
| | | const fields = formConfigData.value.fields; |
| | | const timeField = findLeaveTimeTemplateField(fields); |
| | | if (timeField?.key) void formValues[timeField.key]; |
| | | return computeLeaveDurationDisplay(fields, formValues); |
| | | }); |
| | | |
| | | const overtimeHoursText = computed(() => { |
| | | if (!isOvertimeModule.value) return ""; |
| | | const fields = formConfigData.value.fields; |
| | | const timeField = findOvertimeTimeTemplateField(fields); |
| | | if (timeField?.key) void formValues[timeField.key]; |
| | | return computeOvertimeHoursDisplay(fields, formValues); |
| | | }); |
| | | |
| | | const applicantPickerValue = computed(() => { |
| | | const f = findApplicantTemplateField(formConfigData.value.fields); |
| | | return f?.key ? formValues[f.key] : undefined; |
| | | }); |
| | | |
| | | const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n); |
| | | const approveTypeText = type => (type === "OR" ? "æç¾" : "ä¼ç¾"); |
| | | |
| | | const selectSheetTitle = computed( |
| | | () => (activeSelectField.value?.label ? `éæ©${activeSelectField.value.label}` : "è¯·éæ©") |
| | | ); |
| | | |
| | | const selectSheetActions = computed(() => { |
| | | const field = activeSelectField.value; |
| | | if (!field) return []; |
| | | return resolveFieldOptions(field, { |
| | | users: pickerUserList.value, |
| | | depts: pickerDeptList.value, |
| | | }).map(opt => ({ |
| | | name: opt.label, |
| | | value: opt.value, |
| | | })); |
| | | }); |
| | | |
| | | const formItemClass = field => { |
| | | if (isTextareaField(field)) return "form-item-textarea"; |
| | | if (isDatetimerangeField(field)) return "form-item-daterange"; |
| | | if (isSelectField(field) || isDateLikeField(field)) return "form-item-select"; |
| | | return "form-item-inline"; |
| | | }; |
| | | |
| | | /** å¤è¡ææ¬ãæ¥æèå´ï¼æ ç¾ç½®é¡¶ï¼é¿å
é¿ææ¡å¨çªåå
æè¡ */ |
| | | const formItemLabelPosition = field => { |
| | | if (isTextareaField(field) || isDatetimerangeField(field)) return "top"; |
| | | return "left"; |
| | | }; |
| | | |
| | | const getRangePartDisplay = (field, part) => { |
| | | const parts = parseDatetimerangeValue(formValues[field.key]); |
| | | const val = part === "start" ? parts.start : parts.end; |
| | | return val ? formatFieldDisplayValue({ type: "datetime" }, val) : ""; |
| | | }; |
| | | |
| | | const openRangePicker = (field, part) => { |
| | | activeDateField.value = field; |
| | | activeRangePart.value = part; |
| | | const parts = parseDatetimerangeValue(formValues[field.key]); |
| | | const val = part === "start" ? parts.start : parts.end; |
| | | datePickerTs.value = parseFieldDateToTs(val) ?? Date.now(); |
| | | showDatePicker.value = true; |
| | | }; |
| | | |
| | | const getSelectDisplayText = field => { |
| | | const stored = formValues[field.key]; |
| | | const options = resolveFieldOptions(field, { |
| | | users: pickerUserList.value, |
| | | depts: pickerDeptList.value, |
| | | }); |
| | | const matched = options.find( |
| | | opt => |
| | | String(opt.value) === String(stored) || String(opt.label) === String(stored) |
| | | ); |
| | | return ( |
| | | matched?.label || |
| | | getFieldOptionLabel(field, stored) || |
| | | (stored !== undefined && stored !== null ? String(stored) : "") |
| | | ); |
| | | }; |
| | | |
| | | const initFormValues = fields => { |
| | | Object.keys(formValues).forEach(key => { |
| | |
| | | }); |
| | | fields.forEach(field => { |
| | | if (!field?.key) return; |
| | | formValues[field.key] = field.value ?? field.defaultValue ?? ""; |
| | | formValues[field.key] = getFieldInitialValue(field); |
| | | }); |
| | | }; |
| | | |
| | | const openDatePicker = fieldKey => { |
| | | activeDateFieldKey.value = fieldKey; |
| | | const current = formValues[fieldKey]; |
| | | datePickerTs.value = current ? new Date(current).getTime() : Date.now(); |
| | | const openSelectPicker = field => { |
| | | const options = resolveFieldOptions(field, { |
| | | users: pickerUserList.value, |
| | | depts: pickerDeptList.value, |
| | | }); |
| | | if (!options.length) { |
| | | uni.showToast({ title: "è¯¥åæ®µæªé
ç½®ä¸æé项", icon: "none" }); |
| | | return; |
| | | } |
| | | activeSelectField.value = field; |
| | | showSelectSheet.value = true; |
| | | }; |
| | | |
| | | const onSelectOption = action => { |
| | | const key = activeSelectField.value?.key; |
| | | if (key) { |
| | | formValues[key] = action.value; |
| | | } |
| | | showSelectSheet.value = false; |
| | | activeSelectField.value = null; |
| | | }; |
| | | |
| | | const openDatePicker = field => { |
| | | activeDateField.value = field; |
| | | const current = formValues[field.key]; |
| | | datePickerTs.value = parseFieldDateToTs(current) ?? Date.now(); |
| | | showDatePicker.value = true; |
| | | }; |
| | | |
| | | const onDatePickerCancel = () => { |
| | | showDatePicker.value = false; |
| | | activeDateField.value = null; |
| | | }; |
| | | |
| | | const onDateConfirm = e => { |
| | | const ts = e?.value ?? datePickerTs.value; |
| | | if (activeDateFieldKey.value) { |
| | | formValues[activeDateFieldKey.value] = formatDateToYMD(ts); |
| | | const field = activeDateField.value; |
| | | if (field?.key) { |
| | | if (isDatetimerangeField(field)) { |
| | | const parts = parseDatetimerangeValue(formValues[field.key]); |
| | | const formatted = formatFieldDateValue({ type: "datetime" }, ts); |
| | | formValues[field.key] = joinDatetimerangeValue( |
| | | activeRangePart.value === "start" ? formatted : parts.start, |
| | | activeRangePart.value === "end" ? formatted : parts.end |
| | | ); |
| | | } else { |
| | | formValues[field.key] = formatFieldDateValue(field, ts); |
| | | } |
| | | } |
| | | showDatePicker.value = false; |
| | | onDatePickerCancel(); |
| | | }; |
| | | |
| | | const validateForm = () => { |
| | |
| | | uni.showToast({ title: "请è¾å
¥å®¡æ¹æ é¢", icon: "none" }); |
| | | return false; |
| | | } |
| | | for (const field of formConfigData.value.fields) { |
| | | for (const field of displayTemplateFields.value) { |
| | | if (!field.required) continue; |
| | | const val = formValues[field.key]; |
| | | if (val === undefined || val === null || String(val).trim() === "") { |
| | | uni.showToast({ title: `请填å${field.label}`, icon: "none" }); |
| | | const action = |
| | | isSelectField(field) || isDateLikeField(field) || isDatetimerangeField(field) |
| | | ? "è¯·éæ©" |
| | | : "请填å"; |
| | | uni.showToast({ title: `${action}${field.label}`, icon: "none" }); |
| | | return false; |
| | | } |
| | | if (isDatetimerangeField(field)) { |
| | | const { start, end } = parseDatetimerangeValue(val); |
| | | if (!start || !end) { |
| | | uni.showToast({ title: `è¯·å®æ´éæ©${field.label}`, icon: "none" }); |
| | | return false; |
| | | } |
| | | } |
| | | if (isSelectField(field)) { |
| | | const options = resolveFieldOptions(field, { |
| | | users: pickerUserList.value, |
| | | depts: pickerDeptList.value, |
| | | }); |
| | | if ( |
| | | options.length && |
| | | !options.some( |
| | | opt => |
| | | String(opt.value) === String(val) || String(opt.label) === String(val) |
| | | ) |
| | | ) { |
| | | uni.showToast({ title: `${field.label}éé¡¹æ æ`, icon: "none" }); |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | if (!detail.value?.nodes?.length) { |
| | | uni.showToast({ title: "æ¨¡æ¿æªé
ç½®å®¡æ¹æµç¨", icon: "none" }); |
| | | return false; |
| | | } |
| | | const moduleMsg = validateModuleExtras( |
| | | moduleKey.value, |
| | | formConfigData.value.fields, |
| | | formValues, |
| | | extraForm |
| | | ); |
| | | if (moduleMsg) { |
| | | uni.showToast({ title: moduleMsg, icon: "none" }); |
| | | return false; |
| | | } |
| | | return true; |
| | | }; |
| | | |
| | | const buildFormConfigPayload = () => |
| | | JSON.stringify({ |
| | | const buildFormConfigPayload = () => { |
| | | syncModuleExtrasToFormValues( |
| | | moduleKey.value, |
| | | formValues, |
| | | extraForm, |
| | | formConfigData.value.fields |
| | | ); |
| | | const allFields = formConfigData.value.fields || []; |
| | | return JSON.stringify({ |
| | | prompt: formConfigData.value.prompt, |
| | | fields: formConfigData.value.fields.map(field => ({ |
| | | fields: allFields.map(field => ({ |
| | | ...field, |
| | | value: formValues[field.key] ?? "", |
| | | })), |
| | | }); |
| | | }; |
| | | |
| | | const buildSavePayload = () => ({ |
| | | templateId: detail.value.id, |
| | |
| | | templateId: row.templateId ?? detail.value?.id, |
| | | templateName: row.templateName ?? detail.value?.templateName, |
| | | businessId: row.businessId, |
| | | businessType: row.businessType, |
| | | businessType: row.businessType ?? detail.value?.businessType, |
| | | title: form.title.trim(), |
| | | status: row.status || "PENDING", |
| | | currentLevel: row.currentLevel, |
| | | applicantId: row.applicantId, |
| | | applicantName: row.applicantName, |
| | | applyTime: row.applyTime, |
| | | finishTime: row.finishTime, |
| | | createUser: row.createUser, |
| | | createTime: row.createTime, |
| | | updateUser: row.updateUser, |
| | | updateTime: row.updateTime, |
| | | deptId: row.deptId, |
| | | deleted: row.deleted, |
| | | formConfig: buildFormConfigPayload(), |
| | | approveAction: row.approveAction, |
| | | approveComment: row.approveComment, |
| | | }; |
| | | }; |
| | | |
| | |
| | | submitApi(payload) |
| | | .then(() => { |
| | | uni.showToast({ |
| | | title: isEditMode.value ? "ä¿åæå" : "æäº¤æå", |
| | | title: isEditMode.value ? "ä¿®æ¹æå" : "æäº¤æå", |
| | | icon: "success", |
| | | }); |
| | | if (isEditMode.value) { |
| | |
| | | }) |
| | | .catch(() => { |
| | | uni.showToast({ |
| | | title: isEditMode.value ? "ä¿å失败" : "æäº¤å¤±è´¥", |
| | | title: isEditMode.value ? "ä¿®æ¹å¤±è´¥" : "æäº¤å¤±è´¥", |
| | | icon: "none", |
| | | }); |
| | | }) |
| | |
| | | try { |
| | | await loadTemplateDetail(); |
| | | if (!detail.value) return; |
| | | initFormValues(formConfigData.value.fields); |
| | | initFormValues(displayTemplateFields.value); |
| | | resetModuleExtras(); |
| | | if (!form.title && detail.value.templateName) { |
| | | form.title = `${detail.value.templateName}ç³è¯·`; |
| | | } |
| | |
| | | const row = uni.getStorageSync(EDIT_STORAGE_KEY); |
| | | if (!row || String(row.id) !== String(instanceId.value)) { |
| | | uni.showToast({ title: "æªè·åå°å®¡æ¹æ°æ®", icon: "none" }); |
| | | setTimeout(() => uni.navigateBack(), 500); |
| | | return; |
| | | } |
| | | uni.removeStorageSync(EDIT_STORAGE_KEY); |
| | | instanceRow.value = row; |
| | | if (!moduleKey.value) { |
| | | moduleKey.value = inferModuleKeyFromRow(row); |
| | | } |
| | | templateId.value = row.templateId; |
| | | form.title = row.title || ""; |
| | | |
| | |
| | | detail.value = null; |
| | | try { |
| | | await loadTemplateDetail(); |
| | | initFormValues(formConfigData.value.fields); |
| | | if (!detail.value) return; |
| | | initFormValues(displayTemplateFields.value); |
| | | applyModuleExtrasFromRow(); |
| | | if (isTransferModule.value) { |
| | | await ensureTransferLookupData(); |
| | | syncOriginalPostFromApplicant(applicantPickerValue.value); |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | }; |
| | | |
| | | function resetModuleExtras() { |
| | | extraForm.leaveBalanceDays = undefined; |
| | | extraForm.originalPostName = ""; |
| | | } |
| | | |
| | | function applyModuleExtrasFromRow() { |
| | | const loaded = loadModuleExtrasFromRow( |
| | | moduleKey.value, |
| | | instanceRow.value, |
| | | formValues |
| | | ); |
| | | if (loaded.leaveBalanceDays != null) { |
| | | extraForm.leaveBalanceDays = loaded.leaveBalanceDays; |
| | | } |
| | | if (loaded.originalPostName) { |
| | | extraForm.originalPostName = loaded.originalPostName; |
| | | } |
| | | } |
| | | |
| | | async function ensureTransferLookupData() { |
| | | if (!transferUserPool.value.length) { |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | transferUserPool.value = unwrapUserArray(res); |
| | | } catch { |
| | | transferUserPool.value = []; |
| | | } |
| | | } |
| | | if (!Object.keys(postIdToName.value).length) { |
| | | try { |
| | | const res = await findPostOptions(); |
| | | const rows = res?.data ?? res?.rows ?? []; |
| | | postIdToName.value = buildPostIdToNameMap(Array.isArray(rows) ? rows : []); |
| | | } catch { |
| | | postIdToName.value = {}; |
| | | } |
| | | } |
| | | } |
| | | |
| | | function syncOriginalPostFromApplicant(uid) { |
| | | if (!isTransferModule.value) return; |
| | | if (!uid) { |
| | | extraForm.originalPostName = ""; |
| | | return; |
| | | } |
| | | const user = userById(transferUserPool.value, uid); |
| | | extraForm.originalPostName = resolveOriginalPostName(user, postIdToName.value); |
| | | } |
| | | |
| | | const goBack = () => { |
| | | uni.navigateBack(); |
| | | }; |
| | | |
| | | const loadPickerSourceData = () => { |
| | | userListNoPageByTenantId() |
| | | .then(res => { |
| | | pickerUserList.value = res?.data || []; |
| | | }) |
| | | .catch(() => { |
| | | pickerUserList.value = []; |
| | | }); |
| | | getDept() |
| | | .then(res => { |
| | | pickerDeptList.value = res?.data || []; |
| | | }) |
| | | .catch(() => { |
| | | pickerDeptList.value = []; |
| | | }); |
| | | }; |
| | | |
| | | watch(applicantPickerValue, async uid => { |
| | | if (!isTransferModule.value) return; |
| | | await ensureTransferLookupData(); |
| | | syncOriginalPostFromApplicant(uid); |
| | | }); |
| | | |
| | | onLoad(options => { |
| | | moduleKey.value = options?.moduleKey || ""; |
| | | loadPickerSourceData(); |
| | | if (isTransferModule.value) { |
| | | ensureTransferLookupData(); |
| | | } |
| | | if (options?.id) { |
| | | instanceId.value = options.id; |
| | | loadForEdit(); |
| | |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "@/static/scss/form-common.scss"; |
| | | |
| | | $primary: #2979ff; |
| | | $text: #1f2d3d; |
| | | $text-secondary: #606266; |
| | | $text-muted: #909399; |
| | | $bg-page: #f0f3f8; |
| | | $radius-lg: 12px; |
| | | $radius-md: 10px; |
| | | $shadow-card: 0 2px 12px rgba(31, 45, 61, 0.05); |
| | | |
| | | .approve-apply-page { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 100vh; |
| | | background: #f0f3f8; |
| | | background: $bg-page; |
| | | } |
| | | |
| | | .form-scroll { |
| | |
| | | |
| | | .loading-text { |
| | | font-size: 14px; |
| | | color: $text-muted; |
| | | } |
| | | |
| | | .form-section { |
| | | margin-bottom: 10px; |
| | | border-radius: $radius-lg; |
| | | overflow: hidden; |
| | | box-shadow: $shadow-card; |
| | | } |
| | | |
| | | :deep(.form-section .u-cell-group__title) { |
| | | padding: 12px 16px 8px !important; |
| | | font-size: 15px !important; |
| | | font-weight: 600 !important; |
| | | color: $text !important; |
| | | background: #fff !important; |
| | | } |
| | | |
| | | :deep(.form-section .u-form-item) { |
| | | padding: 0 16px !important; |
| | | } |
| | | |
| | | :deep(.form-section .u-form-item__body) { |
| | | padding: 10px 0 !important; |
| | | min-height: auto !important; |
| | | } |
| | | |
| | | :deep(.form-item-name .u-form-item__body) { |
| | | flex-direction: row !important; |
| | | align-items: center !important; |
| | | } |
| | | |
| | | :deep(.form-item-name .u-form-item__content) { |
| | | flex: 1 !important; |
| | | min-width: 0 !important; |
| | | justify-content: flex-end !important; |
| | | } |
| | | |
| | | :deep(.name-input-inline), |
| | | :deep(.name-input-inline .u-input__content) { |
| | | width: 100% !important; |
| | | flex: 1 !important; |
| | | } |
| | | |
| | | :deep(.name-input-inline input), |
| | | :deep(.name-input-inline .u-input__content__field-wrapper__field) { |
| | | width: 100% !important; |
| | | text-align: right !important; |
| | | font-size: 15px !important; |
| | | } |
| | | |
| | | :deep(.form-item-readonly .u-form-item__body) { |
| | | align-items: center !important; |
| | | } |
| | | |
| | | :deep(.form-item-readonly .u-form-item__content) { |
| | | flex: 1 !important; |
| | | min-width: 0 !important; |
| | | justify-content: flex-end !important; |
| | | } |
| | | |
| | | :deep(.form-item-readonly .u-input__content__field-wrapper__field) { |
| | | text-align: right !important; |
| | | color: #303133 !important; |
| | | } |
| | | |
| | | .dynamic-form { |
| | | padding: 0 0 4px; |
| | | } |
| | | |
| | | :deep(.dynamic-form .u-form-item) { |
| | | padding: 0 16px !important; |
| | | } |
| | | |
| | | :deep(.dynamic-form .u-form-item__body) { |
| | | padding: 10px 0 !important; |
| | | min-height: auto !important; |
| | | } |
| | | |
| | | :deep(.form-item-inline .u-form-item__body) { |
| | | flex-direction: row !important; |
| | | align-items: center !important; |
| | | } |
| | | |
| | | :deep(.form-item-inline .u-form-item__content) { |
| | | flex: 1 !important; |
| | | min-width: 0 !important; |
| | | justify-content: flex-end !important; |
| | | } |
| | | |
| | | :deep(.form-item-inline input), |
| | | :deep(.form-item-inline .u-input__content__field-wrapper__field) { |
| | | text-align: right !important; |
| | | font-size: 15px !important; |
| | | } |
| | | |
| | | :deep(.form-item-select .u-form-item__body) { |
| | | align-items: center !important; |
| | | } |
| | | |
| | | :deep(.form-item-select .u-form-item__content) { |
| | | flex: 1 !important; |
| | | min-width: 0 !important; |
| | | justify-content: flex-end !important; |
| | | } |
| | | |
| | | :deep(.form-item-textarea .u-form-item__body), |
| | | :deep(.form-item-daterange .u-form-item__body) { |
| | | flex-direction: column !important; |
| | | align-items: stretch !important; |
| | | padding: 10px 0 12px !important; |
| | | } |
| | | |
| | | :deep(.form-item-textarea .u-form-item__body__left), |
| | | :deep(.form-item-daterange .u-form-item__body__left) { |
| | | width: 100% !important; |
| | | max-width: 100% !important; |
| | | margin-bottom: 8px !important; |
| | | padding-right: 0 !important; |
| | | } |
| | | |
| | | :deep(.form-item-textarea .u-form-item__body__left__content__label), |
| | | :deep(.form-item-daterange .u-form-item__body__left__content__label) { |
| | | white-space: normal !important; |
| | | line-height: 1.45 !important; |
| | | font-size: 14px !important; |
| | | } |
| | | |
| | | :deep(.form-item-textarea .u-form-item__body__right), |
| | | :deep(.form-item-daterange .u-form-item__body__right) { |
| | | width: 100% !important; |
| | | flex: none !important; |
| | | } |
| | | |
| | | :deep(.form-item-textarea .u-form-item__content), |
| | | :deep(.form-item-daterange .u-form-item__content) { |
| | | width: 100% !important; |
| | | justify-content: stretch !important; |
| | | } |
| | | |
| | | :deep(.dynamic-form .u-form-item__body__left__content__label) { |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .field-trigger { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | gap: 6px; |
| | | width: 100%; |
| | | min-width: 0; |
| | | } |
| | | |
| | | :deep(.field-trigger .u-input) { |
| | | flex: 1 !important; |
| | | min-width: 0 !important; |
| | | } |
| | | |
| | | :deep(.field-trigger .u-input__content__field-wrapper__field) { |
| | | text-align: right !important; |
| | | font-size: 15px !important; |
| | | } |
| | | |
| | | .daterange-fill { |
| | | width: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .range-fill-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 8px 10px; |
| | | background: #f7f9fc; |
| | | border: 1px solid #eef1f6; |
| | | border-radius: 8px; |
| | | } |
| | | |
| | | .range-fill-label { |
| | | flex-shrink: 0; |
| | | width: 36px; |
| | | font-size: 13px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .section { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | .range-fill-sep { |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | text-align: center; |
| | | } |
| | | |
| | | :deep(.range-fill-row .u-input) { |
| | | flex: 1 !important; |
| | | min-width: 0 !important; |
| | | } |
| | | |
| | | .section-card { |
| | | margin-bottom: 10px; |
| | | background: #fff; |
| | | border-radius: $radius-lg; |
| | | overflow: hidden; |
| | | box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05); |
| | | box-shadow: $shadow-card; |
| | | } |
| | | |
| | | .section-head { |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f2f4f7; |
| | | } |
| | | |
| | | .section-title { |
| | | padding: 12px 16px; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #1f2d3d; |
| | | border-bottom: 1px solid #f2f4f7; |
| | | border-left: 3px solid #2979ff; |
| | | padding-left: 13px; |
| | | } |
| | | |
| | | .form-body { |
| | | padding: 8px 16px 16px; |
| | | } |
| | | |
| | | .form-row { |
| | | padding: 10px 0; |
| | | border-bottom: 1px solid #f5f7fa; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | &--field { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | } |
| | | |
| | | .form-label { |
| | | display: block; |
| | | margin-bottom: 8px; |
| | | font-size: 14px; |
| | | color: #606266; |
| | | |
| | | &.required::before { |
| | | content: "*"; |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | } |
| | | } |
| | | |
| | | .form-readonly { |
| | | font-size: 14px; |
| | | color: #303133; |
| | | color: $text; |
| | | padding-left: 10px; |
| | | border-left: 3px solid $primary; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .form-prompt { |
| | | margin: 12px 16px 0; |
| | | padding: 10px 12px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | color: $text-secondary; |
| | | background: #f8fafc; |
| | | border-radius: 8px; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .date-trigger { |
| | | width: 100%; |
| | | .flow-wrap { |
| | | padding: 10px 16px 14px; |
| | | } |
| | | |
| | | .flow-list { |
| | | .flow-node-block { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | |
| | | .flow-node-card { |
| | | background: #fafbfd; |
| | | border: 1px solid #e8eef5; |
| | | border-radius: $radius-md; |
| | | padding: 12px; |
| | | } |
| | | |
| | | .flow-card { |
| | | padding: 12px; |
| | | margin-bottom: 8px; |
| | | background: #f8fafc; |
| | | .node-header { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .node-level-badge { |
| | | width: 26px; |
| | | height: 26px; |
| | | border-radius: 8px; |
| | | border: 1px solid #eef2f6; |
| | | background: $primary; |
| | | color: #fff; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | &:last-child { |
| | | margin-bottom: 0; |
| | | .node-level-text { |
| | | flex: 1; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | } |
| | | |
| | | .approve-type-row { |
| | | display: flex; |
| | | background: #f0f3f8; |
| | | border-radius: 8px; |
| | | padding: 3px; |
| | | margin-bottom: 10px; |
| | | |
| | | &--readonly { |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | |
| | | .flow-card-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .flow-level { |
| | | .type-btn { |
| | | flex: 1; |
| | | text-align: center; |
| | | padding: 8px 0; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | color: $text-secondary; |
| | | border-radius: 6px; |
| | | |
| | | &.active { |
| | | background: #fff; |
| | | color: $primary; |
| | | font-weight: 500; |
| | | } |
| | | } |
| | | |
| | | .flow-type { |
| | | font-size: 13px; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .approver-tags { |
| | | .approver-list { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .approver-tag { |
| | | padding: 4px 10px; |
| | | font-size: 13px; |
| | | color: #303133; |
| | | .approver-chip { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 6px 12px 6px 6px; |
| | | background: #fff; |
| | | border: 1px solid #dce8f8; |
| | | border-radius: 16px; |
| | | border-radius: 24px; |
| | | box-shadow: 0 2px 6px rgba(41, 121, 255, 0.06); |
| | | } |
| | | |
| | | .approver-avatar { |
| | | width: 26px; |
| | | height: 26px; |
| | | border-radius: 50%; |
| | | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| | | color: #fff; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .approver-name { |
| | | font-size: 13px; |
| | | color: $text; |
| | | max-width: 120px; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .flow-connector { |
| | | display: flex; |
| | | justify-content: center; |
| | | padding: 4px 0; |
| | | } |
| | | |
| | | .flow-connector-line { |
| | | width: 2px; |
| | | height: 14px; |
| | | background: #d0dff0; |
| | | } |
| | | |
| | | .empty-hint { |
| | | padding: 12px 16px 16px; |
| | | font-size: 13px; |
| | | color: #909399; |
| | | color: $text-muted; |
| | | |
| | | &.inline { |
| | | padding: 0; |
| | |
| | | .empty-wrap { |
| | | padding: 48px 20px; |
| | | } |
| | | |
| | | .module-extra-block { |
| | | margin-top: 8px; |
| | | padding-top: 8px; |
| | | border-top: 1px dashed #e8ecf0; |
| | | } |
| | | |
| | | .readonly-with-unit { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | width: 100%; |
| | | justify-content: flex-end; |
| | | } |
| | | |
| | | .readonly-with-unit :deep(.u-input) { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .unit-text { |
| | | flex-shrink: 0; |
| | | font-size: 14px; |
| | | color: $text-muted; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | OA / 审æ¹ç®¡ç / 审æ¹å¤ç |
| | | å·®æ
/è´¹ç¨æ¥éä½¿ç¨æ¥é详æ
+ 审æ¹å表 approve æ¥å£ |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <scroll-view v-if="displayReady" |
| | | class="oa-detail-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <ReimburseInstanceDetailBody v-if="isReimburse" |
| | | :reimburse-row="reimburseRow" |
| | | :module-key="detailModuleKey" /> |
| | | |
| | | <ApproveInstanceDetailBody v-else |
| | | :row="row" |
| | | :module-key="detailModuleKey" /> |
| | | |
| | | <view class="section-card opinion-card"> |
| | | <view class="section-head"> |
| | | <text class="section-title">å®¡æ¹æè§</text> |
| | | </view> |
| | | <view class="opinion-wrap"> |
| | | <up-textarea v-model="approveOpinion" |
| | | placeholder="éè¿å¯ç空ï¼é©³å请填åå
·ä½åå " |
| | | maxlength="500" |
| | | count |
| | | height="100" |
| | | border="surround" /> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view v-else |
| | | class="oa-empty"> |
| | | <up-empty mode="data" |
| | | :text="loading ? 'å è½½ä¸' : 'æªè·åå°å®¡æ¹æ°æ®'" /> |
| | | </view> |
| | | |
| | | <view v-if="displayReady" |
| | | class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | :class="{ 'is-disabled': submitting }" |
| | | @click="goBack">åæ¶</text> |
| | | <text class="oa-footer-btn btn-danger" |
| | | :class="{ 'is-disabled': submitting }" |
| | | @click="submitApprove('rejected')">驳å</text> |
| | | <text class="oa-footer-btn btn-success" |
| | | :class="{ 'is-disabled': submitting }" |
| | | @click="submitApprove('approved')">éè¿</text> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue"; |
| | | import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue"; |
| | | import { approveApprovalInstance } from "@/api/oa/approvalInstance.js"; |
| | | import { |
| | | buildApproveInstanceDto, |
| | | canApproveInstance, |
| | | loadInstanceRow, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js"; |
| | | import { |
| | | inferReimburseModuleKeyFromInstance, |
| | | isReimburseApprovalInstance, |
| | | loadReimburseDetailForInstance, |
| | | } from "../../_utils/reimburseApproveBridge.js"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | |
| | | const row = ref(null); |
| | | const reimburseRow = ref(null); |
| | | const loading = ref(false); |
| | | const approveOpinion = ref(""); |
| | | const submitting = ref(false); |
| | | |
| | | const isReimburse = computed(() => isReimburseApprovalInstance(row.value)); |
| | | |
| | | const detailModuleKey = computed(() => { |
| | | if (isReimburse.value) { |
| | | return ( |
| | | reimburseRow.value?.moduleKey || |
| | | inferReimburseModuleKeyFromInstance(row.value) |
| | | ); |
| | | } |
| | | return inferModuleKeyFromRow(row.value); |
| | | }); |
| | | |
| | | const pageTitle = computed(() => { |
| | | if (isReimburse.value) { |
| | | const label = getApprovalModuleConfig(detailModuleKey.value)?.label || "æ¥é"; |
| | | return `${label}审æ¹`; |
| | | } |
| | | return "审æ¹å¤ç"; |
| | | }); |
| | | |
| | | const displayReady = computed(() => |
| | | isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value) |
| | | ); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const submitApprove = uiResult => { |
| | | if (!row.value?.id || submitting.value) return; |
| | | |
| | | if (uiResult === "rejected" && !(approveOpinion.value || "").trim()) { |
| | | uni.showToast({ title: "é©³åæ¶è¯·å¡«åå®¡æ¹æè§", icon: "none" }); |
| | | return; |
| | | } |
| | | |
| | | submitting.value = true; |
| | | const dto = buildApproveInstanceDto( |
| | | row.value.id, |
| | | uiResult, |
| | | approveOpinion.value |
| | | ); |
| | | |
| | | approveApprovalInstance(dto) |
| | | .then(() => { |
| | | uni.showToast({ |
| | | title: uiResult === "approved" ? "å·²éè¿" : "已驳å", |
| | | icon: "success", |
| | | }); |
| | | setTimeout(() => { |
| | | const pages = getCurrentPages(); |
| | | const prevRoute = pages[pages.length - 2]?.route || ""; |
| | | const delta = prevRoute.includes("approve-list/detail") ? 2 : 1; |
| | | uni.navigateBack({ delta }); |
| | | }, 400); |
| | | }) |
| | | .catch(() => { |
| | | uni.showToast({ title: "å®¡æ¹æä½å¤±è´¥", icon: "none" }); |
| | | }) |
| | | .finally(() => { |
| | | submitting.value = false; |
| | | }); |
| | | }; |
| | | |
| | | onLoad(async options => { |
| | | if (!options?.id) { |
| | | uni.showToast({ title: "缺å°å®¡æ¹ ID", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | const cached = loadInstanceRow(options.id); |
| | | if (!cached) { |
| | | uni.showToast({ title: "请ä»å表è¿å
¥", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | if (!canApproveInstance(cached)) { |
| | | uni.showToast({ title: "å½åå®¡æ¹æ 鿍å¤ç", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | row.value = cached; |
| | | if (isReimburseApprovalInstance(cached)) { |
| | | loading.value = true; |
| | | try { |
| | | const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached); |
| | | reimburseRow.value = mapped; |
| | | } catch { |
| | | uni.showToast({ title: "å è½½æ¥é详æ
失败", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | OA / 审æ¹ç®¡ç / 审æ¹è¯¦æ
|
| | | è·¯ç±ï¼/pages/oa/ApproveManage/approve-list/detail |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <scroll-view v-if="displayReady" |
| | | class="oa-detail-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <ReimburseInstanceDetailBody v-if="isReimburse" |
| | | :reimburse-row="reimburseRow" |
| | | :module-key="detailModuleKey" /> |
| | | <ApproveInstanceDetailBody v-else |
| | | :row="row" |
| | | :module-key="detailModuleKey" /> |
| | | </scroll-view> |
| | | |
| | | <view v-else |
| | | class="oa-empty"> |
| | | <up-empty mode="data" |
| | | :text="loading ? 'å è½½ä¸' : 'æªè·åå°å®¡æ¹æ°æ®'" /> |
| | | </view> |
| | | |
| | | <view v-if="displayReady" |
| | | class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | @click="goBack">è¿å</text> |
| | | <text v-if="showEdit" |
| | | class="oa-footer-btn btn-warn" |
| | | @click="goEdit">ä¿®æ¹</text> |
| | | <text v-if="showApprove && !fromBusiness" |
| | | class="oa-footer-btn btn-primary" |
| | | @click="goApprove">å»å®¡æ¹</text> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue"; |
| | | import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { |
| | | canApproveInstance, |
| | | canEditBusinessInstanceRow, |
| | | canModifyInstance, |
| | | loadInstanceRow, |
| | | stashInstanceRow, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js"; |
| | | import { |
| | | inferReimburseModuleKeyFromInstance, |
| | | isReimburseApprovalInstance, |
| | | loadReimburseDetailForInstance, |
| | | resolveFinReimbursementIdFromInstance, |
| | | stashReimburseEditFromApprove, |
| | | } from "../../_utils/reimburseApproveBridge.js"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { canEditReimbursementRow } from "../../_utils/finReimbursementMappers.js"; |
| | | |
| | | const userStore = useUserStore(); |
| | | const fromBusiness = ref(false); |
| | | const row = ref(null); |
| | | const reimburseRow = ref(null); |
| | | const loading = ref(false); |
| | | |
| | | const detailModuleKey = computed(() => { |
| | | if (isReimburse.value) { |
| | | return ( |
| | | reimburseRow.value?.moduleKey || |
| | | inferReimburseModuleKeyFromInstance(row.value) |
| | | ); |
| | | } |
| | | return inferModuleKeyFromRow(row.value); |
| | | }); |
| | | |
| | | const isReimburse = computed(() => isReimburseApprovalInstance(row.value)); |
| | | |
| | | const pageTitle = computed(() => { |
| | | if (isReimburse.value) { |
| | | return getApprovalModuleConfig(detailModuleKey.value)?.label |
| | | ? `${getApprovalModuleConfig(detailModuleKey.value).label}详æ
` |
| | | : "æ¥é详æ
"; |
| | | } |
| | | return "审æ¹è¯¦æ
"; |
| | | }); |
| | | |
| | | const displayReady = computed(() => |
| | | isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value) |
| | | ); |
| | | |
| | | const showEdit = computed(() => { |
| | | if (isReimburse.value) { |
| | | return canEditReimbursementRow(reimburseRow.value); |
| | | } |
| | | if (fromBusiness.value) { |
| | | return canEditBusinessInstanceRow(row.value); |
| | | } |
| | | return canModifyInstance(row.value, userStore); |
| | | }); |
| | | |
| | | const showApprove = computed(() => canApproveInstance(row.value)); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const goEdit = () => { |
| | | if (!showEdit.value) return; |
| | | if (isReimburse.value) { |
| | | const mk = detailModuleKey.value; |
| | | const rid = resolveFinReimbursementIdFromInstance(row.value); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | stashReimburseEditFromApprove(mk, rid); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | return; |
| | | } |
| | | if (!row.value?.id) return; |
| | | const mk = detailModuleKey.value; |
| | | const q = mk ? `&moduleKey=${mk}` : ""; |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListApply}?id=${row.value.id}${q}`, |
| | | }); |
| | | }; |
| | | |
| | | const goApprove = () => { |
| | | if (!row.value?.id) return; |
| | | stashInstanceRow(row.value); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListApprove}?id=${row.value.id}`, |
| | | }); |
| | | }; |
| | | |
| | | onLoad(async options => { |
| | | fromBusiness.value = options?.from === "business"; |
| | | if (!options?.id) { |
| | | uni.showToast({ title: "缺å°å®¡æ¹ ID", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | const cached = loadInstanceRow(options.id); |
| | | if (!cached) { |
| | | uni.showToast({ title: "请ä»å表è¿å
¥è¯¦æ
", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | row.value = cached; |
| | | if (isReimburseApprovalInstance(cached)) { |
| | | loading.value = true; |
| | | try { |
| | | const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached); |
| | | reimburseRow.value = mapped; |
| | | } catch { |
| | | uni.showToast({ title: "å è½½æ¥é详æ
失败", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | </style> |
| | |
| | | <!-- |
| | | OA / 审æ¹ç®¡ç / 审æ¹å表 |
| | | è·¯ç±ï¼/pages/oa/ApproveManage/approve-list/index |
| | | --> |
| | | <template> |
| | | <view class="approve-list-page sales-account"> |
| | | <view class="oa-approval-page"> |
| | | <PageHeader title="审æ¹å表" |
| | | @back="goBack" /> |
| | | <view class="search-section"> |
| | | <view class="search-bar"> |
| | | <view class="search-input"> |
| | | <up-input v-model="queryParams.keyword" |
| | | class="search-text" |
| | | placeholder="å®¡æ¹æ é¢ / 审æ¹ç¼å·" |
| | | clearable |
| | | @confirm="handleSearch" /> |
| | | </view> |
| | | <view class="filter-button" |
| | | @click="handleSearch"> |
| | | <up-icon name="search" |
| | | size="24" |
| | | color="#999" /> |
| | | </view> |
| | | |
| | | <view class="oa-toolbar"> |
| | | <view class="oa-filter-chip active-search"> |
| | | <up-icon name="search" |
| | | size="18" |
| | | color="#666" /> |
| | | <up-input v-model="queryParams.keyword" |
| | | class="chip-input" |
| | | placeholder="å®¡æ¹æ é¢ / 审æ¹ç¼å·" |
| | | clearable |
| | | border="none" |
| | | @confirm="handleSearch" /> |
| | | </view> |
| | | <view class="oa-icon-btn" |
| | | @click="handleSearch"> |
| | | <up-icon name="search" |
| | | size="20" |
| | | color="#2979ff" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <scroll-view class="list-scroll" |
| | | <scroll-view class="oa-list-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false" |
| | | :style="{ height: listScrollHeight + 'px' }" |
| | | @scrolltolower="loadMore"> |
| | | <view v-if="list.length" |
| | | class="ledger-list"> |
| | | class="oa-card-list"> |
| | | <view v-for="item in list" |
| | | :key="item.id" |
| | | class="ledger-item"> |
| | | <view class="item-header"> |
| | | <view class="item-left"> |
| | | <view class="document-icon"> |
| | | <up-icon name="file-text" |
| | | size="16" |
| | | color="#ffffff" /> |
| | | class="oa-card" |
| | | @click="openDetail(item)"> |
| | | <view class="oa-card-head"> |
| | | <view class="oa-card-title-wrap"> |
| | | <text class="oa-card-title">{{ item.title || item.instanceNo || "-" }}</text> |
| | | <text v-if="item.templateName" |
| | | class="oa-card-sub">{{ item.templateName }}</text> |
| | | </view> |
| | | <text :class="['oa-status', businessStatusClass(item.status)]"> |
| | | {{ statusText(item.status) }} |
| | | </text> |
| | | </view> |
| | | |
| | | <view class="oa-card-body"> |
| | | <view class="oa-info-grid"> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">审æ¹ç¼å·</text> |
| | | <text class="oa-info-value">{{ item.instanceNo || "-" }}</text> |
| | | </view> |
| | | <text class="item-id">{{ item.title || item.instanceNo || "-" }}</text> |
| | | </view> |
| | | <u-tag :type="statusTagType(item.status)" |
| | | :text="statusText(item.status)" /> |
| | | </view> |
| | | <up-divider /> |
| | | <view class="item-details"> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">审æ¹ç¼å·</text> |
| | | <text class="detail-value">{{ item.instanceNo || "-" }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">模æ¿åç§°</text> |
| | | <text class="detail-value">{{ item.templateName || "-" }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">ä¸å¡åç§°</text> |
| | | <text class="detail-value">{{ item.businessName || "-" }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">ç³è¯·äºº</text> |
| | | <text class="detail-value">{{ item.applicantName || "-" }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">å½å级å«</text> |
| | | <text class="detail-value">{{ formatLevel(item.currentLevel) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">å½å审æ¹äºº</text> |
| | | <text class="detail-value">{{ currentApproverName(item) }}</text> |
| | | </view> |
| | | <view class="detail-row"> |
| | | <text class="detail-label">ç³è¯·æ¶é´</text> |
| | | <text class="detail-value">{{ item.applyTime || "-" }}</text> |
| | | </view> |
| | | <view v-if="item.finishTime" |
| | | class="detail-row"> |
| | | <text class="detail-label">宿æ¶é´</text> |
| | | <text class="detail-value">{{ item.finishTime }}</text> |
| | | <view v-if="item.businessName" |
| | | class="oa-info-row"> |
| | | <text class="oa-info-label">ä¸å¡åç§°</text> |
| | | <text class="oa-info-value">{{ item.businessName }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·äºº</text> |
| | | <text class="oa-info-value">{{ item.applicantName || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">å½å审æ¹äºº</text> |
| | | <text class="oa-info-value">{{ currentApproverName(item) }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·æ¶é´</text> |
| | | <text class="oa-info-value">{{ formatDateTime(item.applyTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-if="canEdit(item) || item.isApprove" |
| | | class="action-buttons"> |
| | | <up-button v-if="canEdit(item)" |
| | | class="action-btn" |
| | | size="small" |
| | | @click.stop="goEdit(item)"> |
| | | ç¼è¾ |
| | | </up-button> |
| | | <up-button v-if="item.isApprove" |
| | | class="action-btn" |
| | | size="small" |
| | | type="primary" |
| | | @click.stop="handleApprove(item)"> |
| | | å®¡æ¹ |
| | | </up-button> |
| | | |
| | | <view v-if="canModify(item) || item.isApprove" |
| | | class="oa-card-foot" |
| | | @click.stop> |
| | | <text v-if="canModify(item)" |
| | | class="oa-foot-btn btn-edit" |
| | | @click="goModify(item)">ç¼è¾</text> |
| | | <text v-if="item.isApprove" |
| | | class="oa-foot-btn btn-approve" |
| | | @click="handleApprove(item)">审æ¹</text> |
| | | </view> |
| | | </view> |
| | | <up-loadmore :status="pageStatus" /> |
| | | </view> |
| | | <view v-else |
| | | class="empty-wrap"> |
| | | class="oa-empty"> |
| | | <up-empty mode="list" |
| | | text="ææ å®¡æ¹æ°æ®" /> |
| | | </view> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { reactive, ref } from "vue"; |
| | | import { onMounted, reactive, ref } from "vue"; |
| | | import { onShow } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import { listApprovalInstancePage } from "@/api/oa/approvalInstance.js"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { |
| | | businessStatusClass, |
| | | businessStatusText, |
| | | canModifyInstance, |
| | | stashInstanceRow, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import { |
| | | inferReimburseModuleKeyFromInstance, |
| | | resolveFinReimbursementIdFromInstance, |
| | | stashReimburseEditFromApprove, |
| | | } from "../../_utils/reimburseApproveBridge.js"; |
| | | |
| | | const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row"; |
| | | const userStore = useUserStore(); |
| | | |
| | | const queryParams = reactive({ |
| | | keyword: "", |
| | | }); |
| | | |
| | | const queryParams = reactive({ keyword: "" }); |
| | | const list = ref([]); |
| | | const pageStatus = ref("loadmore"); |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | const listScrollHeight = ref(400); |
| | | |
| | | const page = reactive({ |
| | | current: 1, |
| | | size: 10, |
| | | total: 0, |
| | | }); |
| | | function calcListScrollHeight() { |
| | | const sys = uni.getSystemInfoSync(); |
| | | const statusBar = sys.statusBarHeight || 0; |
| | | const navBar = 44; |
| | | const toolbar = 56; |
| | | const fabGap = 16; |
| | | listScrollHeight.value = Math.max( |
| | | 200, |
| | | sys.windowHeight - statusBar - navBar - toolbar - fabGap |
| | | ); |
| | | } |
| | | |
| | | const STATUS_TEXT = { |
| | | PENDING: "è¿è¡ä¸", |
| | | APPROVED: "å·²éè¿", |
| | | REJECTED: "已驳å", |
| | | const statusText = status => businessStatusText(status); |
| | | |
| | | const formatDateTime = val => { |
| | | if (!val) return "-"; |
| | | return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val); |
| | | }; |
| | | |
| | | const STATUS_TAG = { |
| | | PENDING: "warning", |
| | | APPROVED: "success", |
| | | REJECTED: "error", |
| | | }; |
| | | |
| | | const statusText = status => STATUS_TEXT[status] || status || "-"; |
| | | |
| | | const statusTagType = status => STATUS_TAG[status] || "info"; |
| | | |
| | | const formatLevel = level => { |
| | | if (level == null || level === "") return "-"; |
| | | return `第 ${level} 级`; |
| | | }; |
| | | const canModify = item => canModifyInstance(item, userStore); |
| | | |
| | | const currentApproverName = item => { |
| | | const tasks = item?.tasks; |
| | |
| | | dto.instanceNo = keyword; |
| | | } |
| | | } |
| | | return { |
| | | page: { |
| | | current: page.current, |
| | | size: page.size, |
| | | }, |
| | | approvalInstanceDto: dto, |
| | | }; |
| | | return { current: page.current, size: page.size, ...dto }; |
| | | }; |
| | | |
| | | const getList = () => { |
| | | if (pageStatus.value === "loading" || pageStatus.value === "nomore") return; |
| | | |
| | | pageStatus.value = "loading"; |
| | | listApprovalInstancePage(buildListParams()) |
| | | .then(res => { |
| | | const pageData = res?.data || {}; |
| | | const records = pageData.records || []; |
| | | const total = pageData.total ?? 0; |
| | | |
| | | if (page.current === 1) { |
| | | list.value = records; |
| | | } else { |
| | | list.value = [...list.value, ...records]; |
| | | } |
| | | |
| | | page.total = total; |
| | | if (list.value.length >= total || records.length < page.size) { |
| | | pageStatus.value = "nomore"; |
| | |
| | | } |
| | | }) |
| | | .catch(() => { |
| | | if (page.current === 1) { |
| | | list.value = []; |
| | | } |
| | | if (page.current === 1) list.value = []; |
| | | pageStatus.value = "loadmore"; |
| | | uni.showToast({ title: "æ¥è¯¢å¤±è´¥", icon: "none" }); |
| | | }); |
| | |
| | | }; |
| | | |
| | | const loadMore = () => { |
| | | if (pageStatus.value === "loadmore") { |
| | | getList(); |
| | | } |
| | | if (pageStatus.value === "loadmore") getList(); |
| | | }; |
| | | |
| | | const goBack = () => { |
| | | uni.navigateBack(); |
| | | }; |
| | | const goBack = () => uni.navigateBack(); |
| | | const goAdd = () => uni.navigateTo({ url: OA_NAV.approveListTemplateSelect }); |
| | | |
| | | const goAdd = () => { |
| | | uni.navigateTo({ url: OA_NAV.approveListTemplateSelect }); |
| | | }; |
| | | |
| | | const canEdit = item => |
| | | item?.status === "PENDING" && |
| | | String(item.applicantId) === String(userStore.id); |
| | | |
| | | const goEdit = item => { |
| | | const openDetail = item => { |
| | | if (!item?.id) return; |
| | | uni.setStorageSync(EDIT_STORAGE_KEY, item); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListApply}?id=${item.id}`, |
| | | }); |
| | | stashInstanceRow(item); |
| | | uni.navigateTo({ url: `${OA_NAV.approveListDetail}?id=${item.id}` }); |
| | | }; |
| | | |
| | | const goModify = item => { |
| | | if (!canModify(item)) { |
| | | uni.showToast({ title: "ä»
è¿è¡ä¸çæ¬äººç³è¯·å¯ç¼è¾", icon: "none" }); |
| | | return; |
| | | } |
| | | const mk = inferReimburseModuleKeyFromInstance(item); |
| | | if (mk) { |
| | | const rid = resolveFinReimbursementIdFromInstance(item); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | stashReimburseEditFromApprove(mk, rid); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | return; |
| | | } |
| | | if (!item?.id) return; |
| | | stashInstanceRow(item); |
| | | uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` }); |
| | | }; |
| | | |
| | | const handleApprove = item => { |
| | | if (!item?.id) return; |
| | | uni.showToast({ title: "审æ¹è¯¦æ
页å¾
对æ¥", icon: "none" }); |
| | | if (!item.isApprove) { |
| | | uni.showToast({ title: "å½åå®¡æ¹æ 鿍å¤ç", icon: "none" }); |
| | | return; |
| | | } |
| | | stashInstanceRow(item); |
| | | uni.navigateTo({ url: `${OA_NAV.approveListApprove}?id=${item.id}` }); |
| | | }; |
| | | |
| | | onMounted(() => calcListScrollHeight()); |
| | | |
| | | onShow(() => { |
| | | calcListScrollHeight(); |
| | | handleSearch(); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "@/styles/sales-common.scss"; |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | |
| | | .approve-list-page { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 100vh; |
| | | .active-search { |
| | | padding-right: 4px; |
| | | } |
| | | |
| | | .list-scroll { |
| | | .chip-input { |
| | | flex: 1; |
| | | height: 0; |
| | | padding-bottom: calc(80px + env(safe-area-inset-bottom)); |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .empty-wrap { |
| | | padding: 48px 20px; |
| | | } |
| | | |
| | | .action-buttons { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | gap: 10px; |
| | | margin-top: 12px; |
| | | padding-top: 12px; |
| | | border-top: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .action-btn { |
| | | min-width: 72px; |
| | | :deep(.chip-input .u-input__content) { |
| | | background: transparent !important; |
| | | padding: 0 !important; |
| | | } |
| | | </style> |
| | |
| | | --> |
| | | <template> |
| | | <view class="template-select-page sales-account"> |
| | | <PageHeader title="éæ©å®¡æ¹æ¨¡æ¿" |
| | | <PageHeader :title="pageHeaderTitle" |
| | | @back="goBack" /> |
| | | |
| | | <view v-if="typeOptions.length" |
| | | <view v-if="typeOptions.length && !moduleKey" |
| | | class="step-section"> |
| | | <view class="tabs-wrap"> |
| | | <up-tabs :list="tabList" |
| | |
| | | line-color="#2979ff" |
| | | @click="onTabClick" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="useAllTemplatesFallback && allTemplates.length" |
| | | class="fallback-hint"> |
| | | <text>å½åç±»å䏿 å¹é
模æ¿ï¼å·²æ¾ç¤ºå
¨é¨ {{ allTemplates.length }} 个å¯ç¨æ¨¡æ¿</text> |
| | | </view> |
| | | |
| | | <view class="search-section"> |
| | |
| | | class="loading-wrap"> |
| | | <up-loading-icon mode="circle" /> |
| | | <text class="loading-text">å è½½ä¸...</text> |
| | | </view> |
| | | <view v-else-if="!typeOptions.length" |
| | | class="empty-wrap"> |
| | | <up-empty mode="list" |
| | | text="æªè·åå°å®¡æ¹ç±»å" /> |
| | | </view> |
| | | <view v-else-if="displayList.length" |
| | | class="ledger-list"> |
| | |
| | | import { computed, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import { |
| | | buildTypeLabelMap, |
| | | CUSTOM_TEMPLATE_LIST_TYPE, |
| | | fetchApprovalTemplateTypes, |
| | | buildTypeOptionsFromTemplates, |
| | | FALLBACK_BUSINESS_TYPE_OPTIONS, |
| | | fetchEnabledApprovalTemplates, |
| | | filterTemplatesByBusinessType, |
| | | filterTemplatesByBusinessTypes, |
| | | getBusinessTypeLabel, |
| | | getDefaultTypeTabIndex, |
| | | pickTabIndexWithTemplates, |
| | | } from "../../_utils/approvalTemplateType.js"; |
| | | import { |
| | | getApprovalModuleConfig, |
| | | getModuleMatchingBusinessTypes, |
| | | resolveModuleBusinessType, |
| | | } from "../../_utils/approvalModuleRegistry.js"; |
| | | |
| | | const moduleKey = ref(""); |
| | | const typeOptions = ref([]); |
| | | const typeLabelMap = ref({}); |
| | | /** å
¨é¨èªå®ä¹å·²å¯ç¨æ¨¡æ¿ï¼list/1 䏿¬¡æåï¼ */ |
| | |
| | | typeOptions.value.map(opt => ({ name: opt.name })) |
| | | ); |
| | | |
| | | const moduleConfig = computed(() => |
| | | moduleKey.value ? getApprovalModuleConfig(moduleKey.value) : null |
| | | ); |
| | | |
| | | const pageHeaderTitle = computed(() => { |
| | | if (moduleConfig.value?.label) { |
| | | return `éæ©${moduleConfig.value.label}模æ¿`; |
| | | } |
| | | return "éæ©å®¡æ¹æ¨¡æ¿"; |
| | | }); |
| | | |
| | | const moduleBusinessTypes = computed(() => { |
| | | if (!moduleKey.value) return []; |
| | | return getModuleMatchingBusinessTypes(moduleKey.value, typeOptions.value); |
| | | }); |
| | | |
| | | const currentTypeOption = computed(() => typeOptions.value[activeTab.value]); |
| | | |
| | | /** æ moduleKey ä¸å½å Tab çä¸å°æ¶ï¼å±ç¤ºå
¨é¨æ¨¡æ¿é¿å
ãææ°æ®å´ç©ºç½ã */ |
| | | const useAllTemplatesFallback = computed(() => { |
| | | if (moduleKey.value) return false; |
| | | if (!allTemplates.value.length) return false; |
| | | const businessType = currentTypeOption.value?.value; |
| | | if (businessType == null || businessType === "") return true; |
| | | return filterTemplatesByBusinessType(allTemplates.value, businessType).length === 0; |
| | | }); |
| | | |
| | | const currentSource = computed(() => { |
| | | if (moduleKey.value && moduleBusinessTypes.value.length) { |
| | | const filtered = filterTemplatesByBusinessTypes( |
| | | allTemplates.value, |
| | | moduleBusinessTypes.value |
| | | ); |
| | | if (filtered.length) return filtered; |
| | | return allTemplates.value; |
| | | } |
| | | if (useAllTemplatesFallback.value) { |
| | | return allTemplates.value; |
| | | } |
| | | const businessType = currentTypeOption.value?.value; |
| | | return filterTemplatesByBusinessType(allTemplates.value, businessType); |
| | | }); |
| | |
| | | }); |
| | | |
| | | const emptyText = computed(() => { |
| | | if (allTemplates.value.length === 0) { |
| | | return "ææ å·²å¯ç¨çå®¡æ¹æ¨¡æ¿ï¼è¯·å
å¨ãå®¡æ¹æ¨¡æ¿ãä¸å建并å¯ç¨"; |
| | | } |
| | | if (moduleConfig.value?.label) { |
| | | return `ææ ${moduleConfig.value.label}å¯ç¨æ¨¡æ¿`; |
| | | } |
| | | if (useAllTemplatesFallback.value) { |
| | | return "å½åç±»å䏿 å¹é
模æ¿"; |
| | | } |
| | | const typeName = currentTypeOption.value?.name || "该审æ¹ç±»å"; |
| | | return `ææ ${typeName}ä¸ç模æ¿`; |
| | | return `ææ ${typeName}ä¸ç模æ¿ï¼å¯åæ¢ä¸æ¹ç±»åï¼`; |
| | | }); |
| | | |
| | | const businessTypeText = type => |
| | |
| | | return count != null ? `${count} 个` : "-"; |
| | | }; |
| | | |
| | | const normalizeList = data => { |
| | | const list = Array.isArray(data) |
| | | ? data |
| | | : Array.isArray(data?.records) |
| | | ? data.records |
| | | : []; |
| | | return list.filter(item => String(item?.enabled ?? "1") === "1"); |
| | | }; |
| | | |
| | | const loadCustomTemplates = () => |
| | | listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE) |
| | | .then(res => { |
| | | allTemplates.value = normalizeList(res?.data); |
| | | }) |
| | | .catch(() => { |
| | | allTemplates.value = []; |
| | | uni.showToast({ title: "å 载模æ¿å表失败", icon: "none" }); |
| | | }); |
| | | |
| | | const initPage = async () => { |
| | | loading.value = true; |
| | | keyword.value = ""; |
| | | allTemplates.value = []; |
| | | try { |
| | | const [opts] = await Promise.all([ |
| | | const [opts, templates] = await Promise.all([ |
| | | fetchApprovalTemplateTypes(), |
| | | loadCustomTemplates(), |
| | | fetchEnabledApprovalTemplates(), |
| | | ]); |
| | | typeOptions.value = opts; |
| | | typeLabelMap.value = buildTypeLabelMap(opts); |
| | | activeTab.value = getDefaultTypeTabIndex(opts); |
| | | let resolvedOpts = opts?.length ? opts : buildTypeOptionsFromTemplates(templates); |
| | | if (!resolvedOpts.length) { |
| | | resolvedOpts = [...FALLBACK_BUSINESS_TYPE_OPTIONS]; |
| | | } |
| | | typeOptions.value = resolvedOpts; |
| | | typeLabelMap.value = buildTypeLabelMap(resolvedOpts); |
| | | allTemplates.value = templates; |
| | | |
| | | if (!templates.length) { |
| | | uni.showToast({ |
| | | title: "æªè·åå°å·²å¯ç¨æ¨¡æ¿", |
| | | icon: "none", |
| | | duration: 2500, |
| | | }); |
| | | } |
| | | |
| | | if (moduleKey.value) { |
| | | const resolved = resolveModuleBusinessType(moduleKey.value, opts); |
| | | const idx = opts.findIndex( |
| | | opt => String(opt.value) === String(resolved) |
| | | ); |
| | | activeTab.value = |
| | | idx >= 0 ? idx : pickTabIndexWithTemplates(resolvedOpts, templates); |
| | | } else { |
| | | activeTab.value = pickTabIndexWithTemplates(resolvedOpts, templates); |
| | | } |
| | | } catch { |
| | | typeOptions.value = []; |
| | | typeLabelMap.value = {}; |
| | | uni.showToast({ title: "è·å审æ¹ç±»å失败", icon: "none" }); |
| | | allTemplates.value = []; |
| | | uni.showToast({ title: "å 载模æ¿å¤±è´¥", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | |
| | | uni.showToast({ title: "该模æ¿å·²åç¨", icon: "none" }); |
| | | return; |
| | | } |
| | | const base = `${OA_NAV.approveListApply}?templateId=${item.id}`; |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListApply}?templateId=${item.id}`, |
| | | url: moduleKey.value ? `${base}&moduleKey=${moduleKey.value}` : base, |
| | | }); |
| | | }; |
| | | |
| | | onLoad(() => { |
| | | onLoad(options => { |
| | | moduleKey.value = options?.moduleKey || ""; |
| | | initPage(); |
| | | }); |
| | | </script> |
| | |
| | | min-height: 100vh; |
| | | } |
| | | |
| | | .fallback-hint { |
| | | margin: 8px 12px 0; |
| | | padding: 8px 12px; |
| | | font-size: 12px; |
| | | color: #e6a23c; |
| | | background: #fdf6ec; |
| | | border-radius: 6px; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .step-section { |
| | | background: #fff; |
| | | border-bottom: 1px solid #f0f0f0; |
| | |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import FooterButtons from "@/components/FooterButtons.vue"; |
| | | import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js"; |
| | | import { getFieldEditorTypeLabel } from "../../_utils/approvalFormField.js"; |
| | | import { |
| | | buildTypeLabelMap, |
| | | fetchApprovalTemplateTypes, |
| | |
| | | |
| | | const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; |
| | | const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å
", "ä¸", "å
«", "ä¹", "å"]; |
| | | |
| | | const FIELD_TYPE_MAP = { |
| | | text: "åè¡ææ¬", |
| | | textarea: "å¤è¡ææ¬", |
| | | number: "æ°å", |
| | | date: "æ¥æ", |
| | | }; |
| | | |
| | | const templateId = ref(""); |
| | | const detail = ref(null); |
| | |
| | | return ""; |
| | | }; |
| | | |
| | | const fieldTypeLabel = type => FIELD_TYPE_MAP[type] || type || "-"; |
| | | const fieldTypeLabel = type => getFieldEditorTypeLabel(type); |
| | | |
| | | const approveTypeText = type => (type === "OR" ? "æç¾" : "ä¼ç¾"); |
| | | |
| | |
| | | class="name-input-inline" |
| | | placeholder="请è¾å
¥æ¨¡æ¿åç§°" |
| | | maxlength="50" |
| | | clearable /> |
| | | :disabled="isSystemTemplate" |
| | | :clearable="!isSystemTemplate" /> |
| | | </up-form-item> |
| | | <up-form-item label="审æ¹ç±»å" |
| | | prop="businessType" |
| | | required |
| | | class="form-item-select" |
| | | :class="{ 'form-item-select--disabled': isSystemTemplate }" |
| | | @click="openBusinessTypeSheet"> |
| | | <up-input :model-value="businessTypeText" |
| | | placeholder="è¯·éæ©å®¡æ¹ç±»å" |
| | | readonly /> |
| | | <template #right> |
| | | readonly |
| | | :disabled="isSystemTemplate" /> |
| | | <template v-if="!isSystemTemplate" |
| | | #right> |
| | | <up-icon name="arrow-right" |
| | | @click.stop="openBusinessTypeSheet" /> |
| | | </template> |
| | |
| | | |
| | | <view class="section-card"> |
| | | <view class="section-head section-head--between"> |
| | | <text class="section-title">å¡«æ¥é
ç½®</text> |
| | | <view class="section-head-left"> |
| | | <text class="section-title">å¡«æ¥é¡¹é
ç½®</text> |
| | | <text class="section-count">å
± {{ formConfig.fields.length }} 项</text> |
| | | </view> |
| | | <view class="head-actions"> |
| | | <text class="head-link" |
| | | @click="showPresetSheet = true">é¢è®¾</text> |
| | | <text class="head-link head-link--import" |
| | | :class="{ 'head-link--disabled': !canImportTemplate }" |
| | | @click="openTemplateImport">ä»å·²ææ¨¡æ¿å¯¼å
¥</text> |
| | | <text class="head-link head-link--primary" |
| | | @click="openFieldEditor()">æ·»å </text> |
| | | @click="openFieldEditor()">+ æ·»å å¡«æ¥é¡¹</text> |
| | | </view> |
| | | </view> |
| | | <view class="section-body"> |
| | |
| | | class="field-list"> |
| | | <view v-for="(field, index) in formConfig.fields" |
| | | :key="field.key" |
| | | class="field-item"> |
| | | class="field-item" |
| | | :class="{ 'field-item--locked': isFieldLocked(field) }" |
| | | @click="onFieldItemClick(field, index)"> |
| | | <view class="field-order">{{ index + 1 }}</view> |
| | | <view class="field-main"> |
| | | <view class="field-title-row"> |
| | | <text class="field-name">{{ field.label }}</text> |
| | | <text class="type-tag" |
| | | :class="fieldTypeTagClass(field.type)"> |
| | | {{ fieldTypeLabel(field.type) }} |
| | | </text> |
| | | <text v-if="field.required" |
| | | class="req-tag">å¿
å¡«</text> |
| | | <view class="field-tags"> |
| | | <text class="type-tag" |
| | | :class="fieldTypeTagClass(field.type)"> |
| | | {{ fieldTypeLabel(field.type) }} |
| | | </text> |
| | | <text v-if="field.required" |
| | | class="req-tag">å¿
å¡«</text> |
| | | </view> |
| | | </view> |
| | | <text class="field-key">{{ field.key }}</text> |
| | | <text v-if="field.defaultValue" |
| | | class="field-default">é»è®¤ï¼{{ field.defaultValue }}</text> |
| | | class="field-default"> |
| | | é»è®¤ï¼{{ formatFieldDefaultPreview(field) }} |
| | | </text> |
| | | </view> |
| | | <view class="field-actions"> |
| | | <view v-if="!isFieldLocked(field)" |
| | | class="field-actions" |
| | | @click.stop> |
| | | <view class="icon-btn icon-btn--edit" |
| | | @click="openFieldEditor(field, index)"> |
| | | @click.stop="openFieldEditor(field, index)"> |
| | | <up-icon name="edit-pen" |
| | | size="16" |
| | | color="#2979ff" /> |
| | | </view> |
| | | <view class="icon-btn icon-btn--del" |
| | | @click="removeField(index)"> |
| | | @click.stop="removeField(index)"> |
| | | <up-icon name="trash" |
| | | size="16" |
| | | color="#f56c6c" /> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="field-lock-tag">å
ç½®</view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | |
| | | @cancel="goBack" |
| | | @confirm="handleSubmit" /> |
| | | |
| | | <up-action-sheet :show="showPresetSheet" |
| | | title="ä»é¢è®¾å¯¼å
¥" |
| | | :actions="presetActions" |
| | | @select="onSelectPreset" |
| | | @close="showPresetSheet = false" /> |
| | | <up-action-sheet :show="showTemplateImportSheet" |
| | | title="ä»å·²ææ¨¡æ¿å¯¼å
¥" |
| | | :actions="templateImportActions" |
| | | @select="onSelectImportTemplate" |
| | | @close="showTemplateImportSheet = false" /> |
| | | |
| | | <up-popup :show="showFieldEditor" |
| | | mode="bottom" |
| | |
| | | @close="closeFieldEditor"> |
| | | <view class="field-editor"> |
| | | <view class="sheet-handle" /> |
| | | <text class="editor-title">{{ editingFieldIndex >= 0 ? "ç¼è¾å¡«æ¥é¡¹" : "æ·»å å¡«æ¥é¡¹" }}</text> |
| | | <view class="editor-form"> |
| | | <view class="editor-row"> |
| | | <text class="editor-label required">åæ®µåç§°</text> |
| | | <up-input v-model="fieldDraft.label" |
| | | placeholder="请è¾å
¥" |
| | | clearable /> |
| | | </view> |
| | | <view class="editor-row editor-row--block"> |
| | | <text class="editor-label required">åæ®µç±»å</text> |
| | | <view class="type-chip-grid"> |
| | | <view v-for="opt in FIELD_TYPE_OPTIONS" |
| | | :key="opt.value" |
| | | class="type-chip" |
| | | :class="{ active: fieldDraft.type === opt.value }" |
| | | @click="selectFieldType(opt.value)"> |
| | | {{ opt.name }} |
| | | <view class="editor-header"> |
| | | <text class="editor-title">{{ editingFieldIndex >= 0 ? "ç¼è¾å¡«æ¥é¡¹" : "æ·»å å¡«æ¥é¡¹" }}</text> |
| | | <text class="editor-subtitle">é
ç½®åæ®µå±æ§ãæ ¡éªä¸é»è®¤å¼</text> |
| | | </view> |
| | | <scroll-view class="editor-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <view class="editor-form"> |
| | | <view class="editor-section-card"> |
| | | <view class="editor-section-head"> |
| | | <text class="editor-section-title">åºç¡ä¿¡æ¯</text> |
| | | </view> |
| | | <view class="editor-cell"> |
| | | <text class="editor-label required">æ¾ç¤ºåç§°</text> |
| | | <view class="editor-input-box"> |
| | | <up-input v-model="fieldDraft.label" |
| | | placeholder="å¦ï¼æ¥é说æ" |
| | | border="none" |
| | | clearable /> |
| | | </view> |
| | | </view> |
| | | <view class="editor-cell"> |
| | | <text class="editor-label required">åæ®µæ è¯</text> |
| | | <view class="editor-input-box"> |
| | | <up-input v-model="fieldDraft.key" |
| | | placeholder="å¦ï¼summary" |
| | | border="none" |
| | | clearable /> |
| | | </view> |
| | | </view> |
| | | <view class="editor-cell editor-cell--tap" |
| | | @click="openFieldTypePicker"> |
| | | <text class="editor-label required">æ§ä»¶ç±»å</text> |
| | | <view class="picker-value-row"> |
| | | <text class="picker-value" |
| | | :class="{ 'picker-value--placeholder': !fieldDraft.type }"> |
| | | {{ fieldDraftTypeText || "è¯·éæ©" }} |
| | | </text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#b0b8c4" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="editor-section-card"> |
| | | <view class="editor-section-head"> |
| | | <text class="editor-section-title">æ ¡éªä¸æ ¼å¼</text> |
| | | </view> |
| | | <view class="editor-cell editor-cell--switch"> |
| | | <view class="switch-label-wrap"> |
| | | <text class="editor-label">æ¯å¦å¿
å¡«</text> |
| | | <text class="switch-hint">æäº¤å®¡æ¹æ¶é¡»å¡«å该项</text> |
| | | </view> |
| | | <up-switch v-model="fieldDraft.required" |
| | | active-color="#2979ff" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="isSelectDraft" |
| | | class="editor-section-card"> |
| | | <view class="editor-section-head"> |
| | | <text class="editor-section-title">䏿é项</text> |
| | | </view> |
| | | <view class="editor-cell editor-cell--tap" |
| | | @click="openOptionSourcePicker"> |
| | | <text class="editor-label">éé¡¹æ¥æº</text> |
| | | <view class="picker-value-row"> |
| | | <text class="picker-value">{{ fieldDraftOptionSourceText }}</text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#b0b8c4" /> |
| | | </view> |
| | | </view> |
| | | <view v-if="fieldDraft.optionSource === 'manual'" |
| | | class="manual-options"> |
| | | <text class="manual-options-title">æå¨é项</text> |
| | | <view class="manual-options-table"> |
| | | <view class="option-table-head"> |
| | | <text class="option-col option-col--idx" /> |
| | | <text class="option-col option-col--label">æ¾ç¤ºææ¬</text> |
| | | <text class="option-col option-col--value">é项å¼</text> |
| | | <text class="option-col option-col--action" /> |
| | | </view> |
| | | <view v-for="(opt, optIndex) in fieldDraft.options" |
| | | :key="optIndex" |
| | | class="option-card"> |
| | | <text class="option-idx">{{ optIndex + 1 }}</text> |
| | | <view class="option-input-wrap"> |
| | | <up-input v-model="opt.label" |
| | | placeholder="å¦ï¼å·¥ä½æ¥å ç" |
| | | border="none" |
| | | clearable /> |
| | | </view> |
| | | <view class="option-input-wrap option-input-wrap--value"> |
| | | <up-input v-model="opt.value" |
| | | placeholder="å¦ï¼0" |
| | | border="none" |
| | | clearable /> |
| | | </view> |
| | | <view class="option-del" |
| | | hover-class="option-del--active" |
| | | @click.stop="removeDraftOption(optIndex)"> |
| | | <up-icon name="trash" |
| | | size="16" |
| | | color="#f56c6c" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view class="add-option-btn" |
| | | hover-class="add-option-btn--active" |
| | | @click="addDraftOption"> |
| | | <up-icon name="plus-circle" |
| | | size="16" |
| | | color="#2979ff" /> |
| | | <text>æ·»å é项</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="option-source-tip"> |
| | | <up-icon name="info-circle" |
| | | size="14" |
| | | color="#909399" /> |
| | | <text>åèµ·å®¡æ¹æ¶å°èªå¨å è½½{{ fieldDraftOptionSourceText }}</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="editor-section-card"> |
| | | <view class="editor-section-head"> |
| | | <text class="editor-section-title">é»è®¤å¼</text> |
| | | </view> |
| | | <text class="default-hint"> |
| | | éæ©è¯¥æ¨¡æ¿æäº¤å®¡æ¹æ¶èªå¨é¢å¡«ï¼ç¨æ·ä»å¯ä¿®æ¹ |
| | | </text> |
| | | <view class="editor-cell editor-cell--value"> |
| | | <up-textarea v-if="fieldDraft.type === 'textarea'" |
| | | v-model="fieldDraft.defaultValue" |
| | | placeholder="éå¡«" |
| | | maxlength="500" |
| | | border="surround" |
| | | height="72" /> |
| | | <view v-else-if="fieldDraft.type === 'date'" |
| | | class="picker-value-row picker-value-row--tap" |
| | | @click="openDefaultDatePicker"> |
| | | <text class="picker-value" |
| | | :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }"> |
| | | {{ fieldDraft.defaultValue || "éæ©æ¥æ" }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#909399" /> |
| | | </view> |
| | | <view v-else-if="isDatetimerangeDraft" |
| | | class="daterange-default-wrap"> |
| | | <view class="daterange-default-item" |
| | | @click="openDefaultRangePicker('start')"> |
| | | <text class="daterange-default-label">å¼å§æ¶é´</text> |
| | | <view class="picker-value-row picker-value-row--tap"> |
| | | <text class="picker-value" |
| | | :class="{ 'picker-value--placeholder': !defaultRangeStart }"> |
| | | {{ defaultRangeStart || "éæ©å¼å§æ¶é´" }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#909399" /> |
| | | </view> |
| | | </view> |
| | | <view class="daterange-default-item" |
| | | @click="openDefaultRangePicker('end')"> |
| | | <text class="daterange-default-label">ç»ææ¶é´</text> |
| | | <view class="picker-value-row picker-value-row--tap"> |
| | | <text class="picker-value" |
| | | :class="{ 'picker-value--placeholder': !defaultRangeEnd }"> |
| | | {{ defaultRangeEnd || "éæ©ç»ææ¶é´" }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#909399" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else-if="isSelectDraft" |
| | | class="picker-value-row picker-value-row--tap" |
| | | @click="openDefaultSelectSheet"> |
| | | <text class="picker-value" |
| | | :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }"> |
| | | {{ defaultSelectDisplayText || "éå¡«" }} |
| | | </text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#b0b8c4" /> |
| | | </view> |
| | | <view v-else |
| | | class="editor-input-box"> |
| | | <up-input v-model="fieldDraft.defaultValue" |
| | | :type="fieldDraft.type === 'number' ? 'digit' : 'text'" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | clearable /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view class="editor-row editor-row--block"> |
| | | <text class="editor-label">é»è®¤å¼</text> |
| | | <up-textarea v-if="fieldDraft.type === 'textarea'" |
| | | v-model="fieldDraft.defaultValue" |
| | | placeholder="éå¡«" |
| | | maxlength="500" |
| | | height="72" /> |
| | | <view v-else-if="fieldDraft.type === 'date'" |
| | | class="default-date-row" |
| | | @click="showDefaultDatePicker = true"> |
| | | <up-input :model-value="fieldDraft.defaultValue" |
| | | placeholder="éæ©æ¥æ" |
| | | readonly /> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#909399" /> |
| | | </view> |
| | | <up-input v-else |
| | | v-model="fieldDraft.defaultValue" |
| | | :type="fieldDraft.type === 'number' ? 'digit' : 'text'" |
| | | placeholder="éå¡«" |
| | | clearable /> |
| | | </view> |
| | | <view class="editor-row editor-row--switch"> |
| | | <text class="editor-label">æ¯å¦å¿
å¡«</text> |
| | | <up-switch v-model="fieldDraft.required" /> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | <view class="editor-footer"> |
| | | <view class="editor-btn editor-btn--cancel" |
| | | @click="closeFieldEditor">åæ¶</view> |
| | | <view class="editor-btn editor-btn--confirm" |
| | | @click="confirmFieldEditor">ç¡®å®</view> |
| | | </view> |
| | | |
| | | <view v-if="inlinePickerShow" |
| | | class="editor-picker-layer"> |
| | | <view class="editor-picker-mask" |
| | | @click="closeInlinePicker" /> |
| | | <view class="editor-picker-panel"> |
| | | <view class="editor-picker-head"> |
| | | <text class="editor-picker-cancel" |
| | | @click="closeInlinePicker">åæ¶</text> |
| | | <text class="editor-picker-title">{{ inlinePickerTitle }}</text> |
| | | <text class="editor-picker-placeholder" /> |
| | | </view> |
| | | <scroll-view class="editor-picker-scroll" |
| | | scroll-y> |
| | | <view v-for="(item, pickerIndex) in inlinePickerOptions" |
| | | :key="`${inlinePickerMode}-${pickerIndex}-${item.value}`" |
| | | class="editor-picker-item" |
| | | :class="{ 'editor-picker-item--active': isInlinePickerItemActive(item) }" |
| | | @click="onInlinePickerSelect(item)"> |
| | | <text>{{ item.name }}</text> |
| | | <up-icon v-if="isInlinePickerItemActive(item)" |
| | | name="checkmark" |
| | | size="18" |
| | | color="#2979ff" /> |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </up-popup> |
| | | |
| | | <up-popup :show="showDefaultDatePicker" |
| | | mode="bottom" |
| | | @close="showDefaultDatePicker = false"> |
| | | @close="closeDefaultDatePicker"> |
| | | <up-datetime-picker :show="true" |
| | | v-model="defaultDateTs" |
| | | mode="date" |
| | | @confirm="onDefaultDateConfirm" |
| | | @cancel="showDefaultDatePicker = false" /> |
| | | :mode="defaultDatePickerMode" |
| | | @confirm="onDefaultDatePickerConfirm" |
| | | @cancel="closeDefaultDatePicker" /> |
| | | </up-popup> |
| | | |
| | | <up-popup :show="showUserPicker" |
| | |
| | | import FooterButtons from "@/components/FooterButtons.vue"; |
| | | import { |
| | | addApprovalTemplate, |
| | | getApprovalTemplateDetail, |
| | | listApprovalTemplatePage, |
| | | updateApprovalTemplate, |
| | | } from "@/api/oa/approvalTemplate.js"; |
| | | import { getDept } from "@/api/collaborativeApproval/approvalProcess.js"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user"; |
| | | import { formatDateToYMD } from "@/utils/ruoyi"; |
| | | import { fetchApprovalTemplateTypes } from "../../_utils/approvalTemplateType.js"; |
| | | import { |
| | | buildFieldConfigPayload, |
| | | createEmptyFieldOption, |
| | | parseApprovalFormConfig, |
| | | FIELD_EDITOR_TYPE_OPTIONS, |
| | | FIELD_OPTION_SOURCE_OPTIONS, |
| | | getFieldEditorTypeLabel, |
| | | getFieldOptionLabel, |
| | | getFieldOptionSource, |
| | | getFieldOptionSourceLabel, |
| | | isDatetimerangeField, |
| | | isSelectField, |
| | | formatDatetimerangeDisplay, |
| | | formatFieldDateValue, |
| | | joinDatetimerangeValue, |
| | | parseDatetimerangeValue, |
| | | parseFieldDateToTs, |
| | | resolveFieldOptions, |
| | | } from "../../_utils/approvalFormField.js"; |
| | | import { |
| | | fetchApprovalTemplateTypes, |
| | | isSystemApprovalTemplate, |
| | | } from "../../_utils/approvalTemplateType.js"; |
| | | |
| | | const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; |
| | | |
| | | const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å
", "ä¸", "å
«", "ä¹", "å"]; |
| | | |
| | | const FORM_PRESETS = [ |
| | | { |
| | | name: "éç¨æ¥é", |
| | | prompt: "è¯·å¡«åæ¥éäºç±ãéé¢ç", |
| | | fields: [ |
| | | { key: "reason", label: "æ¥éäºç±", type: "textarea", required: true }, |
| | | { key: "amount", label: "æ¥ééé¢(å
)", type: "number", required: true }, |
| | | { key: "applyDate", label: "ç³è¯·æ¥æ", type: "date", required: true }, |
| | | ], |
| | | }, |
| | | { |
| | | name: "请åç³è¯·", |
| | | prompt: "请填å请åç±»åãèµ·æ¢æ¶é´ç", |
| | | fields: [ |
| | | { key: "leaveType", label: "请åç±»å", type: "text", required: true }, |
| | | { key: "startTime", label: "å¼å§æ¶é´", type: "date", required: true }, |
| | | { key: "endTime", label: "ç»ææ¶é´", type: "date", required: true }, |
| | | { key: "reason", label: "请åäºç±", type: "textarea", required: true }, |
| | | ], |
| | | }, |
| | | { |
| | | name: "éè´ç³è¯·", |
| | | prompt: "请填åéè´äºç±ãé¢ä¼°éé¢ç", |
| | | fields: [ |
| | | { key: "title", label: "éè´äºç±", type: "textarea", required: true }, |
| | | { key: "amount", label: "é¢ä¼°éé¢(å
)", type: "number", required: true }, |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | const FIELD_TYPE_OPTIONS = [ |
| | | { name: "åè¡ææ¬", value: "text" }, |
| | | { name: "å¤è¡ææ¬", value: "textarea" }, |
| | | { name: "æ°å", value: "number" }, |
| | | { name: "æ¥æ", value: "date" }, |
| | | ]; |
| | | |
| | | const formRef = ref(); |
| | | const submitting = ref(false); |
| | | const userList = ref([]); |
| | | const templateId = ref(null); |
| | | |
| | | const showPresetSheet = ref(false); |
| | | const showTemplateImportSheet = ref(false); |
| | | const importTemplateList = ref([]); |
| | | const showFieldEditor = ref(false); |
| | | const inlinePickerShow = ref(false); |
| | | const inlinePickerTitle = ref(""); |
| | | const inlinePickerOptions = ref([]); |
| | | const inlinePickerMode = ref(""); |
| | | const showUserPicker = ref(false); |
| | | const showDefaultDatePicker = ref(false); |
| | | const defaultDatePickerMode = ref("date"); |
| | | const defaultRangePickerPart = ref("start"); |
| | | const defaultDateTs = ref(Date.now()); |
| | | const deptList = ref([]); |
| | | |
| | | const editingFieldIndex = ref(-1); |
| | | const editingNodeIndex = ref(-1); |
| | | const pickerSelectedIds = ref([]); |
| | | /** ç³»ç»æ¨¡æ¿å è½½æ¶éå®çå¡«æ¥é¡¹ keyï¼ä¸å¯ç¼è¾/å é¤ */ |
| | | const lockedFieldKeys = ref(new Set()); |
| | | |
| | | const form = reactive({ |
| | | templateName: "", |
| | |
| | | |
| | | const fieldDraft = reactive({ |
| | | label: "", |
| | | key: "", |
| | | type: "text", |
| | | defaultValue: "", |
| | | required: true, |
| | | optionSource: "manual", |
| | | options: [createEmptyFieldOption()], |
| | | }); |
| | | |
| | | let nodeKeySeed = 1; |
| | |
| | | return matched?.name || ""; |
| | | }); |
| | | |
| | | const presetActions = FORM_PRESETS.map(item => ({ |
| | | name: item.name, |
| | | value: item.name, |
| | | })); |
| | | const canImportTemplate = computed(() => !isSystemTemplate.value); |
| | | |
| | | const templateImportActions = computed(() => |
| | | importTemplateList.value.map(item => { |
| | | const typeTag = isSystemApprovalTemplate(item) ? "ç³»ç»" : "èªå®ä¹"; |
| | | return { |
| | | name: `ã${typeTag}ã${item.templateName || `模æ¿${item.id}`}`, |
| | | value: String(item.id), |
| | | }; |
| | | }) |
| | | ); |
| | | |
| | | const isSelectDraft = computed(() => isSelectField(fieldDraft)); |
| | | |
| | | const isDatetimerangeDraft = computed(() => isDatetimerangeField(fieldDraft)); |
| | | |
| | | const defaultRangeParts = computed(() => |
| | | parseDatetimerangeValue(fieldDraft.defaultValue) |
| | | ); |
| | | |
| | | const defaultRangeStart = computed(() => defaultRangeParts.value.start); |
| | | |
| | | const defaultRangeEnd = computed(() => defaultRangeParts.value.end); |
| | | |
| | | const fieldDraftTypeText = computed(() => getFieldEditorTypeLabel(fieldDraft.type)); |
| | | |
| | | const fieldDraftOptionSourceText = computed(() => |
| | | getFieldOptionSourceLabel(fieldDraft.optionSource) |
| | | ); |
| | | |
| | | const defaultSelectActions = computed(() => { |
| | | const options = resolveFieldOptions(fieldDraft, { |
| | | users: userList.value, |
| | | depts: deptList.value, |
| | | }); |
| | | return [ |
| | | { name: "ä¸è®¾ç½®", value: "" }, |
| | | ...options.map(opt => ({ |
| | | name: opt.label, |
| | | value: opt.value, |
| | | })), |
| | | ]; |
| | | }); |
| | | |
| | | const defaultSelectDisplayText = computed(() => { |
| | | if (!fieldDraft.defaultValue) return ""; |
| | | return ( |
| | | getFieldOptionLabel(fieldDraft, fieldDraft.defaultValue) || |
| | | String(fieldDraft.defaultValue) |
| | | ); |
| | | }); |
| | | |
| | | const enabledBool = computed({ |
| | | get: () => form.enabled === "1", |
| | |
| | | |
| | | const isEditMode = computed(() => templateId.value != null && templateId.value !== ""); |
| | | |
| | | const isSystemTemplate = computed(() => isSystemApprovalTemplate(form)); |
| | | |
| | | const isFieldLocked = field => |
| | | isSystemTemplate.value && lockedFieldKeys.value.has(field?.key); |
| | | |
| | | const pageTitle = computed(() => |
| | | isEditMode.value ? "ç¼è¾å®¡æ¹æ¨¡æ¿" : "æ°å»ºå®¡æ¹æ¨¡æ¿" |
| | | ); |
| | | |
| | | const parseFormConfig = raw => { |
| | | if (!raw) return { prompt: "", fields: [] }; |
| | | try { |
| | | const obj = typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | return { |
| | | prompt: obj?.prompt || "", |
| | | fields: Array.isArray(obj?.fields) ? obj.fields.map(f => ({ ...f })) : [], |
| | | }; |
| | | } catch { |
| | | return { prompt: "", fields: [] }; |
| | | } |
| | | }; |
| | | |
| | | const mapNodesFromRow = nodes => { |
| | | if (!Array.isArray(nodes) || !nodes.length) { |
| | |
| | | form.enabled = String(row.enabled ?? "1"); |
| | | form.description = row.description || ""; |
| | | |
| | | const config = parseFormConfig(row.formConfig); |
| | | const config = parseApprovalFormConfig(row.formConfig); |
| | | formConfig.prompt = config.prompt; |
| | | formConfig.fields = config.fields; |
| | | lockedFieldKeys.value = isSystemApprovalTemplate(row) |
| | | ? new Set(config.fields.map(f => f.key).filter(Boolean)) |
| | | : new Set(); |
| | | flowNodes.value = mapNodesFromRow(row.nodes); |
| | | }; |
| | | |
| | |
| | | |
| | | const levelLabel = n => LEVEL_TEXT[n] || String(n); |
| | | |
| | | const fieldTypeLabel = type => |
| | | FIELD_TYPE_OPTIONS.find(item => item.value === type)?.name || type; |
| | | const fieldTypeLabel = type => getFieldEditorTypeLabel(type); |
| | | |
| | | const formatFieldDefaultPreview = field => { |
| | | if (isDatetimerangeField(field)) { |
| | | return formatDatetimerangeDisplay(field.defaultValue) || field.defaultValue; |
| | | } |
| | | return field.defaultValue; |
| | | }; |
| | | |
| | | const fieldTypeTagClass = type => { |
| | | const map = { |
| | |
| | | textarea: "type-tag--area", |
| | | number: "type-tag--num", |
| | | date: "type-tag--date", |
| | | datetimerange: "type-tag--date", |
| | | select: "type-tag--select", |
| | | }; |
| | | return map[type] || "type-tag--text"; |
| | | }; |
| | |
| | | }; |
| | | |
| | | const openBusinessTypeSheet = () => { |
| | | if (isSystemTemplate.value) return; |
| | | if (!businessTypeOptions.value.length) { |
| | | uni.showToast({ title: "审æ¹ç±»åå è½½ä¸", icon: "none" }); |
| | | return; |
| | |
| | | formRef.value?.validateField?.("businessType"); |
| | | }; |
| | | |
| | | const onSelectPreset = action => { |
| | | const preset = FORM_PRESETS.find(item => item.name === action.value); |
| | | if (!preset) return; |
| | | formConfig.prompt = preset.prompt; |
| | | formConfig.fields = preset.fields.map(field => ({ ...field })); |
| | | showPresetSheet.value = false; |
| | | uni.showToast({ title: "已导å
¥é¢è®¾", icon: "success" }); |
| | | const applyImportedFormConfig = (config, sourceName = "") => { |
| | | const parsed = { |
| | | prompt: config?.prompt || "", |
| | | fields: (config?.fields || []).map(field => ({ ...field })), |
| | | }; |
| | | formConfig.prompt = parsed.prompt; |
| | | formConfig.fields = parsed.fields; |
| | | const tip = sourceName ? `已导å
¥ã${sourceName}ã` : "已导å
¥å¡«æ¥é
ç½®"; |
| | | uni.showToast({ title: tip, icon: "success" }); |
| | | }; |
| | | |
| | | const selectFieldType = type => { |
| | | if (fieldDraft.type === type) return; |
| | | fieldDraft.type = type; |
| | | const doImportFormConfig = (config, sourceName) => { |
| | | const hasExisting = |
| | | !!formConfig.prompt?.trim() || formConfig.fields.length > 0; |
| | | if (!hasExisting) { |
| | | applyImportedFormConfig(config, sourceName); |
| | | return; |
| | | } |
| | | uni.showModal({ |
| | | title: "导å
¥ç¡®è®¤", |
| | | content: `å°ä½¿ç¨ã${sourceName}ãçå¡«æ¥é
ç½®è¦çå½åå
å®¹ï¼æ¯å¦ç»§ç»ï¼`, |
| | | success: res => { |
| | | if (res.confirm) { |
| | | applyImportedFormConfig(config, sourceName); |
| | | } |
| | | }, |
| | | }); |
| | | }; |
| | | |
| | | const applyTemplateImport = templateIdValue => { |
| | | const row = importTemplateList.value.find( |
| | | item => String(item.id) === String(templateIdValue) |
| | | ); |
| | | const sourceName = row?.templateName || "æé模æ¿"; |
| | | const applyFromDetail = detail => { |
| | | const config = parseApprovalFormConfig(detail?.formConfig); |
| | | if (!config.fields.length && !config.prompt) { |
| | | uni.showToast({ title: "è¯¥æ¨¡æ¿æ å¡«æ¥é
ç½®", icon: "none" }); |
| | | return; |
| | | } |
| | | doImportFormConfig(config, sourceName); |
| | | }; |
| | | |
| | | if (row?.formConfig) { |
| | | applyFromDetail(row); |
| | | return; |
| | | } |
| | | |
| | | uni.showLoading({ title: "å è½½é
ç½®...", mask: true }); |
| | | getApprovalTemplateDetail(templateIdValue) |
| | | .then(res => applyFromDetail(res?.data)) |
| | | .catch(() => { |
| | | uni.showToast({ title: "è·å模æ¿é
置失败", icon: "none" }); |
| | | }) |
| | | .finally(() => { |
| | | uni.hideLoading(); |
| | | }); |
| | | }; |
| | | |
| | | const openTemplateImport = () => { |
| | | if (!canImportTemplate.value) { |
| | | uni.showToast({ title: "ç³»ç»å
置模æ¿ä¸å¯å¯¼å
¥", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.showLoading({ title: "å è½½ä¸...", mask: true }); |
| | | listApprovalTemplatePage({ |
| | | page: { current: 1, size: 200 }, |
| | | approvalTemplateDto: {}, |
| | | }) |
| | | .then(res => { |
| | | const records = res?.data?.records || []; |
| | | importTemplateList.value = records.filter( |
| | | item => |
| | | item?.id != null && String(item.id) !== String(templateId.value) |
| | | ); |
| | | if (!importTemplateList.value.length) { |
| | | uni.showToast({ title: "ææ å¯å¯¼å
¥ç模æ¿", icon: "none" }); |
| | | return; |
| | | } |
| | | showTemplateImportSheet.value = true; |
| | | }) |
| | | .catch(() => { |
| | | uni.showToast({ title: "å 载模æ¿å表失败", icon: "none" }); |
| | | }) |
| | | .finally(() => { |
| | | uni.hideLoading(); |
| | | }); |
| | | }; |
| | | |
| | | const onSelectImportTemplate = action => { |
| | | showTemplateImportSheet.value = false; |
| | | const value = String(action?.value ?? ""); |
| | | if (!value) return; |
| | | applyTemplateImport(value); |
| | | }; |
| | | |
| | | const resetFieldDraft = () => { |
| | | fieldDraft.label = ""; |
| | | fieldDraft.key = ""; |
| | | fieldDraft.type = "text"; |
| | | fieldDraft.defaultValue = ""; |
| | | fieldDraft.required = true; |
| | | fieldDraft.optionSource = "manual"; |
| | | fieldDraft.options = [createEmptyFieldOption()]; |
| | | }; |
| | | |
| | | const onDefaultDateConfirm = e => { |
| | | fieldDraft.defaultValue = formatDateToYMD(e.value); |
| | | const resolveActionValue = (action, options) => { |
| | | if (action?.value !== undefined && action?.value !== null) { |
| | | return action.value; |
| | | } |
| | | const name = action?.name; |
| | | if (name == null) return undefined; |
| | | return options.find(opt => opt.name === name)?.value; |
| | | }; |
| | | |
| | | const onSelectFieldType = action => { |
| | | const nextType = resolveActionValue(action, FIELD_EDITOR_TYPE_OPTIONS); |
| | | if (!nextType || fieldDraft.type === nextType) return; |
| | | fieldDraft.type = nextType; |
| | | fieldDraft.defaultValue = ""; |
| | | if (!isSelectField(fieldDraft)) { |
| | | fieldDraft.optionSource = "manual"; |
| | | fieldDraft.options = [createEmptyFieldOption()]; |
| | | } else if (!fieldDraft.options?.length) { |
| | | fieldDraft.options = [createEmptyFieldOption()]; |
| | | } |
| | | }; |
| | | |
| | | const openInlinePicker = (title, options, mode) => { |
| | | inlinePickerTitle.value = title; |
| | | inlinePickerOptions.value = options; |
| | | inlinePickerMode.value = mode; |
| | | inlinePickerShow.value = true; |
| | | }; |
| | | |
| | | const closeInlinePicker = () => { |
| | | inlinePickerShow.value = false; |
| | | inlinePickerMode.value = ""; |
| | | inlinePickerOptions.value = []; |
| | | }; |
| | | |
| | | const isInlinePickerItemActive = item => { |
| | | if (inlinePickerMode.value === "fieldType") { |
| | | return String(fieldDraft.type) === String(item.value); |
| | | } |
| | | if (inlinePickerMode.value === "optionSource") { |
| | | return String(fieldDraft.optionSource) === String(item.value); |
| | | } |
| | | if (inlinePickerMode.value === "defaultValue") { |
| | | const val = fieldDraft.defaultValue; |
| | | if (val === "" || val === undefined || val === null) { |
| | | return item.value === "" || item.value === undefined || item.value === null; |
| | | } |
| | | return String(val) === String(item.value); |
| | | } |
| | | return false; |
| | | }; |
| | | |
| | | const onInlinePickerSelect = item => { |
| | | if (inlinePickerMode.value === "fieldType") { |
| | | onSelectFieldType(item); |
| | | } else if (inlinePickerMode.value === "optionSource") { |
| | | onSelectOptionSource(item); |
| | | } else if (inlinePickerMode.value === "defaultValue") { |
| | | onSelectDefaultOption(item); |
| | | } |
| | | closeInlinePicker(); |
| | | }; |
| | | |
| | | const openFieldTypePicker = () => { |
| | | openInlinePicker( |
| | | "æ§ä»¶ç±»å", |
| | | FIELD_EDITOR_TYPE_OPTIONS.map(item => ({ |
| | | name: item.name, |
| | | value: item.value, |
| | | })), |
| | | "fieldType" |
| | | ); |
| | | }; |
| | | |
| | | const onSelectOptionSource = action => { |
| | | const nextSource = resolveActionValue(action, FIELD_OPTION_SOURCE_OPTIONS); |
| | | if (!nextSource) return; |
| | | fieldDraft.optionSource = nextSource; |
| | | fieldDraft.defaultValue = ""; |
| | | if (nextSource === "manual" && !fieldDraft.options?.length) { |
| | | fieldDraft.options = [createEmptyFieldOption()]; |
| | | } |
| | | }; |
| | | |
| | | const openOptionSourcePicker = () => { |
| | | openInlinePicker( |
| | | "éé¡¹æ¥æº", |
| | | FIELD_OPTION_SOURCE_OPTIONS.map(item => ({ |
| | | name: item.name, |
| | | value: item.value, |
| | | })), |
| | | "optionSource" |
| | | ); |
| | | }; |
| | | |
| | | const addDraftOption = () => { |
| | | fieldDraft.options.push(createEmptyFieldOption()); |
| | | }; |
| | | |
| | | const removeDraftOption = index => { |
| | | if (fieldDraft.options.length <= 1) { |
| | | fieldDraft.options[0] = createEmptyFieldOption(); |
| | | return; |
| | | } |
| | | fieldDraft.options.splice(index, 1); |
| | | }; |
| | | |
| | | const openDefaultSelectSheet = () => { |
| | | const options = resolveFieldOptions(fieldDraft, { |
| | | users: userList.value, |
| | | depts: deptList.value, |
| | | }); |
| | | if (!options.length) { |
| | | uni.showToast({ title: "请å
é
ç½®ä¸æé项", icon: "none" }); |
| | | return; |
| | | } |
| | | openInlinePicker("é»è®¤å¼", defaultSelectActions.value, "defaultValue"); |
| | | }; |
| | | |
| | | const onSelectDefaultOption = action => { |
| | | fieldDraft.defaultValue = |
| | | action.value === undefined || action.value === null |
| | | ? "" |
| | | : String(action.value); |
| | | }; |
| | | |
| | | const closeDefaultDatePicker = () => { |
| | | showDefaultDatePicker.value = false; |
| | | defaultDatePickerMode.value = "date"; |
| | | defaultRangePickerPart.value = "start"; |
| | | }; |
| | | |
| | | const openDefaultDatePicker = () => { |
| | | defaultDatePickerMode.value = "date"; |
| | | const parsed = Date.parse(fieldDraft.defaultValue); |
| | | defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed; |
| | | showDefaultDatePicker.value = true; |
| | | }; |
| | | |
| | | const openDefaultRangePicker = part => { |
| | | defaultDatePickerMode.value = "datetime"; |
| | | defaultRangePickerPart.value = part; |
| | | const parts = parseDatetimerangeValue(fieldDraft.defaultValue); |
| | | const val = part === "start" ? parts.start : parts.end; |
| | | defaultDateTs.value = parseFieldDateToTs(val) ?? Date.now(); |
| | | showDefaultDatePicker.value = true; |
| | | }; |
| | | |
| | | const onDefaultDatePickerConfirm = e => { |
| | | const ts = e?.value ?? defaultDateTs.value; |
| | | if (defaultDatePickerMode.value === "datetime") { |
| | | const parts = parseDatetimerangeValue(fieldDraft.defaultValue); |
| | | const formatted = formatFieldDateValue({ type: "datetime" }, ts); |
| | | fieldDraft.defaultValue = joinDatetimerangeValue( |
| | | defaultRangePickerPart.value === "start" ? formatted : parts.start, |
| | | defaultRangePickerPart.value === "end" ? formatted : parts.end |
| | | ); |
| | | } else { |
| | | fieldDraft.defaultValue = formatDateToYMD(ts); |
| | | } |
| | | closeDefaultDatePicker(); |
| | | }; |
| | | |
| | | const onFieldItemClick = (field, index) => { |
| | | if (isFieldLocked(field)) return; |
| | | openFieldEditor(field, index); |
| | | }; |
| | | |
| | | const openFieldEditor = (field, index = -1) => { |
| | | if (field && isFieldLocked(field)) { |
| | | uni.showToast({ title: "ç³»ç»å
置填æ¥é¡¹ä¸å¯ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | editingFieldIndex.value = index; |
| | | if (field) { |
| | | fieldDraft.label = field.label; |
| | | fieldDraft.label = field.label || ""; |
| | | fieldDraft.key = field.key || ""; |
| | | fieldDraft.type = field.type || "text"; |
| | | fieldDraft.defaultValue = field.defaultValue ?? ""; |
| | | fieldDraft.required = !!field.required; |
| | | fieldDraft.optionSource = getFieldOptionSource(field); |
| | | fieldDraft.options = normalizeDraftOptions(field); |
| | | } else { |
| | | fieldDraft.label = ""; |
| | | fieldDraft.type = "text"; |
| | | fieldDraft.defaultValue = ""; |
| | | fieldDraft.required = true; |
| | | resetFieldDraft(); |
| | | } |
| | | if (fieldDraft.type === "date" && fieldDraft.defaultValue) { |
| | | const parsed = Date.parse(fieldDraft.defaultValue); |
| | | defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed; |
| | | } else { |
| | | defaultDateTs.value = Date.now(); |
| | | } |
| | | defaultDateTs.value = Date.now(); |
| | | showFieldEditor.value = true; |
| | | }; |
| | | |
| | | const closeFieldEditor = () => { |
| | | closeInlinePicker(); |
| | | showFieldEditor.value = false; |
| | | editingFieldIndex.value = -1; |
| | | }; |
| | | |
| | | const normalizeDraftOptions = field => { |
| | | const options = field?.options; |
| | | if (!Array.isArray(options) || !options.length) { |
| | | return [createEmptyFieldOption()]; |
| | | } |
| | | return options.map(opt => ({ |
| | | label: opt?.label ?? "", |
| | | value: opt?.value != null ? String(opt.value) : "", |
| | | })); |
| | | }; |
| | | |
| | | const buildFieldKey = label => { |
| | |
| | | }; |
| | | |
| | | const confirmFieldEditor = () => { |
| | | if (!fieldDraft.label?.trim()) { |
| | | uni.showToast({ title: "请è¾å
¥å段åç§°", icon: "none" }); |
| | | if ( |
| | | editingFieldIndex.value >= 0 && |
| | | isFieldLocked(formConfig.fields[editingFieldIndex.value]) |
| | | ) { |
| | | uni.showToast({ title: "ç³»ç»å
置填æ¥é¡¹ä¸å¯ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | const defaultValue = String(fieldDraft.defaultValue ?? "").trim(); |
| | | if (!fieldDraft.label?.trim()) { |
| | | uni.showToast({ title: "请è¾å
¥æ¾ç¤ºåç§°", icon: "none" }); |
| | | return; |
| | | } |
| | | const existingKey = |
| | | editingFieldIndex.value >= 0 |
| | | ? formConfig.fields[editingFieldIndex.value]?.key |
| | | : null; |
| | | const payload = { |
| | | key: existingKey || buildFieldKey(fieldDraft.label), |
| | | label: fieldDraft.label.trim(), |
| | | type: fieldDraft.type, |
| | | required: !!fieldDraft.required, |
| | | defaultValue, |
| | | }; |
| | | const draftKey = fieldDraft.key?.trim() || existingKey || buildFieldKey(fieldDraft.label); |
| | | if (!draftKey) { |
| | | uni.showToast({ title: "请è¾å
¥å段æ è¯", icon: "none" }); |
| | | return; |
| | | } |
| | | const duplicateKey = formConfig.fields.some( |
| | | (item, idx) => item.key === draftKey && idx !== editingFieldIndex.value |
| | | ); |
| | | if (duplicateKey) { |
| | | uni.showToast({ title: "åæ®µæ è¯å·²åå¨", icon: "none" }); |
| | | return; |
| | | } |
| | | if (isSelectField(fieldDraft) && fieldDraft.optionSource === "manual") { |
| | | const validOptions = (fieldDraft.options || []).filter( |
| | | opt => opt.label?.trim() && opt.value?.trim() |
| | | ); |
| | | if (!validOptions.length) { |
| | | uni.showToast({ title: "请è³å°é
ç½®ä¸ä¸ªä¸æé项", icon: "none" }); |
| | | return; |
| | | } |
| | | } |
| | | const payload = buildFieldConfigPayload( |
| | | { ...fieldDraft, key: draftKey }, |
| | | existingKey |
| | | ); |
| | | if (editingFieldIndex.value >= 0) { |
| | | formConfig.fields.splice(editingFieldIndex.value, 1, payload); |
| | | } else { |
| | |
| | | }; |
| | | |
| | | const removeField = index => { |
| | | const field = formConfig.fields[index]; |
| | | if (isFieldLocked(field)) { |
| | | uni.showToast({ title: "ç³»ç»å
置填æ¥é¡¹ä¸å¯å é¤", icon: "none" }); |
| | | return; |
| | | } |
| | | formConfig.fields.splice(index, 1); |
| | | }; |
| | | |
| | |
| | | .catch(() => { |
| | | userList.value = []; |
| | | }); |
| | | getDept() |
| | | .then(res => { |
| | | deptList.value = res?.data || []; |
| | | }) |
| | | .catch(() => { |
| | | deptList.value = []; |
| | | }); |
| | | }); |
| | | </script> |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | .section-head-left { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .section-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | |
| | | padding-left: 10px; |
| | | border-left: 3px solid $primary; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .section-count { |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | padding-left: 13px; |
| | | } |
| | | |
| | | .head-actions { |
| | |
| | | font-size: 14px; |
| | | color: $text-secondary; |
| | | |
| | | &--primary { |
| | | color: $primary; |
| | | font-weight: 500; |
| | | &--import { |
| | | color: $text-secondary; |
| | | padding: 6px 12px; |
| | | border: 1px solid #dce3ed; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | &--disabled { |
| | | color: #c0c4cc; |
| | | border-color: #ebeef5; |
| | | background: #f5f7fa; |
| | | } |
| | | |
| | | &--primary { |
| | | color: #fff; |
| | | font-weight: 500; |
| | | padding: 6px 14px; |
| | | border: none; |
| | | border-radius: 8px; |
| | | background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%); |
| | | box-shadow: 0 2px 8px rgba(41, 121, 255, 0.25); |
| | | } |
| | | } |
| | | |
| | | :deep(.form-item-select--disabled .u-form-item__body) { |
| | | opacity: 0.65; |
| | | } |
| | | |
| | | .section-body { |
| | |
| | | .field-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 10px 12px; |
| | | background: #f8fafc; |
| | | gap: 12px; |
| | | padding: 14px; |
| | | background: #fff; |
| | | border-radius: $radius-md; |
| | | border: 1px solid #eef2f6; |
| | | border: 1px solid #e8eef5; |
| | | box-shadow: 0 1px 4px rgba(31, 45, 61, 0.04); |
| | | transition: border-color 0.2s, box-shadow 0.2s; |
| | | |
| | | &:active:not(.field-item--locked) { |
| | | border-color: #c6daf5; |
| | | box-shadow: 0 2px 8px rgba(41, 121, 255, 0.08); |
| | | } |
| | | |
| | | &--locked { |
| | | background: #fafbfd; |
| | | } |
| | | } |
| | | |
| | | .field-order { |
| | | width: 28px; |
| | | height: 28px; |
| | | border-radius: 8px; |
| | | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| | | color: #fff; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .field-main { |
| | |
| | | .field-title-row { |
| | | display: flex; |
| | | align-items: center; |
| | | flex-wrap: wrap; |
| | | gap: 6px; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .field-name { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .field-tags { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .field-key { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | font-family: ui-monospace, monospace; |
| | | } |
| | | |
| | | .field-lock-tag { |
| | | flex-shrink: 0; |
| | | font-size: 11px; |
| | | color: #909399; |
| | | padding: 4px 8px; |
| | | background: #f0f2f5; |
| | | border-radius: 4px; |
| | | } |
| | | |
| | | .type-tag { |
| | |
| | | &--date { |
| | | color: #18a058; |
| | | background: #e8faf0; |
| | | } |
| | | |
| | | &--select { |
| | | color: #9c27b0; |
| | | background: #f6edfc; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | .empty-mini { |
| | | padding: 20px 0; |
| | | padding: 32px 16px; |
| | | text-align: center; |
| | | font-size: 13px; |
| | | color: $text-muted; |
| | | background: #fafbfd; |
| | | border: 1px dashed #dce8f5; |
| | | border-radius: 10px; |
| | | } |
| | | |
| | | .flow-wrap { |
| | |
| | | } |
| | | |
| | | .sheet-handle { |
| | | width: 40px; |
| | | width: 36px; |
| | | height: 4px; |
| | | margin: 10px auto 6px; |
| | | background: #e4e7ed; |
| | | margin: 10px auto 4px; |
| | | background: #d8dde6; |
| | | border-radius: 2px; |
| | | } |
| | | |
| | | .field-editor, |
| | | .field-editor .sheet-handle { |
| | | background: #c8ced8; |
| | | } |
| | | |
| | | .field-editor { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | max-height: 88vh; |
| | | background: #f5f7fb; |
| | | border-radius: 16px 16px 0 0; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .user-picker { |
| | | position: relative; |
| | | padding: 0 18px calc(18px + env(safe-area-inset-bottom)); |
| | | background: #fff; |
| | | max-height: 85vh; |
| | | } |
| | | |
| | | .editor-title { |
| | | .editor-header { |
| | | padding: 4px 20px 12px; |
| | | background: #fff; |
| | | text-align: center; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | } |
| | | |
| | | .editor-subtitle { |
| | | display: block; |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | } |
| | | |
| | | .editor-picker-layer { |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | top: 0; |
| | | bottom: 0; |
| | | z-index: 20; |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: flex-end; |
| | | } |
| | | |
| | | .editor-picker-mask { |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | top: 0; |
| | | bottom: 0; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .editor-picker-panel { |
| | | position: relative; |
| | | z-index: 1; |
| | | background: #fff; |
| | | border-radius: 16px 16px 0 0; |
| | | max-height: 55vh; |
| | | padding-bottom: env(safe-area-inset-bottom); |
| | | } |
| | | |
| | | .editor-picker-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 14px 18px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .editor-picker-cancel { |
| | | font-size: 15px; |
| | | color: #909399; |
| | | min-width: 48px; |
| | | } |
| | | |
| | | .editor-picker-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | text-align: center; |
| | | margin-bottom: 14px; |
| | | } |
| | | |
| | | .editor-picker-placeholder { |
| | | min-width: 48px; |
| | | } |
| | | |
| | | .editor-picker-scroll { |
| | | max-height: calc(55vh - 52px); |
| | | } |
| | | |
| | | .editor-picker-item { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 16px 18px; |
| | | font-size: 16px; |
| | | color: $text; |
| | | border-bottom: 1px solid #f5f7fa; |
| | | |
| | | &--active { |
| | | color: $primary; |
| | | background: #f5f9ff; |
| | | } |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .editor-scroll { |
| | | flex: 1; |
| | | height: 0; |
| | | max-height: 62vh; |
| | | } |
| | | |
| | | .editor-form { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 12px; |
| | | gap: 10px; |
| | | padding: 12px 16px 16px; |
| | | } |
| | | |
| | | .editor-row { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | .editor-section-card { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | padding: 14px 14px 4px; |
| | | box-shadow: 0 1px 6px rgba(31, 45, 61, 0.05); |
| | | } |
| | | |
| | | .editor-section-head { |
| | | margin-bottom: 10px; |
| | | padding-bottom: 10px; |
| | | border-bottom: 1px solid #f2f4f7; |
| | | } |
| | | |
| | | .editor-section-title { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | padding-left: 8px; |
| | | border-left: 3px solid $primary; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .editor-cell { |
| | | margin-bottom: 14px; |
| | | |
| | | &--tap:active .picker-value-row { |
| | | background: #eef4ff; |
| | | border-color: #c6daf5; |
| | | } |
| | | |
| | | &--switch { |
| | | flex-direction: row; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 4px 0; |
| | | gap: 12px; |
| | | padding: 4px 0 10px; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | &--value { |
| | | margin-bottom: 10px; |
| | | } |
| | | } |
| | | |
| | | .switch-label-wrap { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | } |
| | | |
| | | .switch-hint { |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | } |
| | | |
| | | .editor-input-box { |
| | | background: #f7f9fc; |
| | | border: 1px solid #e8ecf2; |
| | | border-radius: 10px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | :deep(.editor-input-box .u-input) { |
| | | background: transparent !important; |
| | | } |
| | | |
| | | .default-hint { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | line-height: 1.5; |
| | | margin: -4px 0 10px; |
| | | padding: 0 2px; |
| | | } |
| | | |
| | | .manual-options { |
| | | margin: 4px 0 12px; |
| | | padding-top: 4px; |
| | | } |
| | | |
| | | .manual-options-title { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .manual-options-table { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .option-table-head { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 0 4px 4px; |
| | | } |
| | | |
| | | .option-col { |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | font-weight: 500; |
| | | |
| | | &--idx { |
| | | width: 22px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | &--label { |
| | | flex: 1.4; |
| | | min-width: 0; |
| | | } |
| | | |
| | | &--value { |
| | | flex: 0.9; |
| | | min-width: 72px; |
| | | } |
| | | |
| | | &--action { |
| | | width: 32px; |
| | | flex-shrink: 0; |
| | | } |
| | | } |
| | | |
| | | .option-card { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 8px 10px; |
| | | background: #f8fafc; |
| | | border: 1px solid #e8ecf2; |
| | | border-radius: 10px; |
| | | } |
| | | |
| | | .option-idx { |
| | | width: 22px; |
| | | height: 22px; |
| | | flex-shrink: 0; |
| | | border-radius: 6px; |
| | | background: #eef2f8; |
| | | color: $text-muted; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | line-height: 22px; |
| | | text-align: center; |
| | | } |
| | | |
| | | .option-input-wrap { |
| | | flex: 1.4; |
| | | min-width: 0; |
| | | background: #fff; |
| | | border: 1px solid #e4e8ef; |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | |
| | | &--value { |
| | | flex: 0.9; |
| | | min-width: 72px; |
| | | } |
| | | } |
| | | |
| | | :deep(.option-input-wrap .u-input) { |
| | | background: transparent !important; |
| | | } |
| | | |
| | | :deep(.option-input-wrap input), |
| | | :deep(.option-input-wrap .u-input__content__field-wrapper__field) { |
| | | font-size: 14px !important; |
| | | height: 36px !important; |
| | | min-height: 36px !important; |
| | | padding: 0 10px !important; |
| | | } |
| | | |
| | | .option-del { |
| | | flex-shrink: 0; |
| | | width: 32px; |
| | | height: 32px; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | border: 1px solid #fde2e2; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .option-del--active { |
| | | background: #fef0f0; |
| | | } |
| | | |
| | | .add-option-btn { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 6px; |
| | | margin-top: 10px; |
| | | padding: 11px; |
| | | border: 1.5px dashed #b8d4ff; |
| | | border-radius: 10px; |
| | | background: linear-gradient(180deg, #f8fbff 0%, #f0f6ff 100%); |
| | | color: $primary; |
| | | font-size: 14px; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .add-option-btn--active { |
| | | background: #e8f2ff; |
| | | border-color: $primary; |
| | | } |
| | | |
| | | .option-source-tip { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 6px; |
| | | padding: 10px 12px; |
| | | margin-bottom: 10px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | font-size: 12px; |
| | | color: $text-muted; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .editor-title { |
| | | display: block; |
| | | font-size: 17px; |
| | | font-weight: 600; |
| | | color: $text; |
| | | } |
| | | |
| | | .picker-value-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | min-height: 44px; |
| | | padding: 0 14px; |
| | | background: #f7f9fc; |
| | | border: 1px solid #e8ecf2; |
| | | border-radius: 10px; |
| | | gap: 8px; |
| | | transition: background 0.15s, border-color 0.15s; |
| | | |
| | | &--tap:active { |
| | | background: #eef4ff; |
| | | border-color: #c6daf5; |
| | | } |
| | | } |
| | | |
| | | .picker-value { |
| | | flex: 1; |
| | | min-width: 0; |
| | | font-size: 15px; |
| | | color: $text; |
| | | text-align: left; |
| | | line-height: 1.4; |
| | | |
| | | &--placeholder { |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | |
| | | .editor-label { |
| | | font-size: 14px; |
| | | display: block; |
| | | font-size: 13px; |
| | | font-weight: 500; |
| | | color: $text-secondary; |
| | | margin-bottom: 8px; |
| | | |
| | | &.required::before { |
| | | content: "*"; |
| | | color: #f56c6c; |
| | | margin-right: 4px; |
| | | margin-right: 3px; |
| | | } |
| | | } |
| | | |
| | | .editor-row .input-box, |
| | | .editor-row .textarea-box { |
| | | background: #f7f9fc; |
| | | border-radius: 10px; |
| | | border: 1px solid #eef1f6; |
| | | .editor-cell--switch .editor-label { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .daterange-default-wrap { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .daterange-default-item { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .daterange-default-label { |
| | | font-size: 13px; |
| | | color: $text-secondary; |
| | | } |
| | | |
| | | .type-chip-grid { |
| | |
| | | } |
| | | } |
| | | |
| | | .default-date-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 0 12px; |
| | | min-height: 44px; |
| | | background: #f7f9fc; |
| | | border-radius: 10px; |
| | | border: 1px solid #eef1f6; |
| | | } |
| | | |
| | | .editor-footer { |
| | | display: flex; |
| | | gap: 10px; |
| | | margin-top: 16px; |
| | | padding-top: 14px; |
| | | border-top: 1px solid #f5f7fa; |
| | | gap: 12px; |
| | | padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); |
| | | background: #fff; |
| | | border-top: 1px solid #eef0f4; |
| | | box-shadow: 0 -4px 12px rgba(31, 45, 61, 0.06); |
| | | } |
| | | |
| | | .editor-btn { |
| | | flex: 1; |
| | | text-align: center; |
| | | padding: 11px 0; |
| | | border-radius: 8px; |
| | | padding: 12px 0; |
| | | border-radius: 10px; |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | |
| | | &--cancel { |
| | | color: $text-secondary; |
| | | background: #f5f7fa; |
| | | border: 1px solid #e4e7ed; |
| | | } |
| | | |
| | | &--confirm { |
| | | color: #fff; |
| | | background: $primary; |
| | | background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%); |
| | | box-shadow: 0 4px 12px rgba(41, 121, 255, 0.35); |
| | | } |
| | | |
| | | &--confirm:active { |
| | | opacity: 0.9; |
| | | } |
| | | } |
| | | |
| | |
| | | @click.stop="goEdit(item)"> |
| | | ç¼è¾ |
| | | </up-button> |
| | | <up-button class="action-btn" |
| | | <up-button v-if="!isSystemTemplate(item)" |
| | | class="action-btn" |
| | | size="small" |
| | | type="error" |
| | | plain |
| | |
| | | buildTypeLabelMap, |
| | | fetchApprovalTemplateTypes, |
| | | getTemplateTypeLabel, |
| | | isSystemApprovalTemplate, |
| | | } from "../../_utils/approvalTemplateType.js"; |
| | | |
| | | const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; |
| | |
| | | |
| | | const businessTypeText = type => |
| | | getTemplateTypeLabel(type, typeLabelMap.value); |
| | | |
| | | const isSystemTemplate = isSystemApprovalTemplate; |
| | | |
| | | const loadTemplateTypes = () => |
| | | fetchApprovalTemplateTypes() |
| | |
| | | |
| | | const handleDelete = item => { |
| | | if (!item?.id) return; |
| | | if (isSystemTemplate(item)) { |
| | | uni.showToast({ title: "ç³»ç»å
置模æ¿ä¸å¯å é¤", icon: "none" }); |
| | | return; |
| | | } |
| | | const name = item.templateName || "该模æ¿"; |
| | | uni.showModal({ |
| | | title: "å é¤ç¡®è®¤", |
| | |
| | | è·¯ç±ï¼/pages/oa/AttendManage/leave-apply/index |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.LEAVE" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - åå¤ç®¡ç - 请åç³è¯· */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "AttendManage/leave-apply"; |
| | | const { config } = useOaPage(pageKey); |
| | | import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| | |
| | | è·¯ç±ï¼/pages/oa/AttendManage/overtime-apply/index |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.OVERTIME" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - åå¤ç®¡ç - å çç³è¯· */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "AttendManage/overtime-apply"; |
| | | const { config } = useOaPage(pageKey); |
| | | import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| | |
| | | è·¯ç±ï¼/pages/oa/HrManage/regular-apply/index |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.REGULAR" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - 人äºç®¡ç - 转æ£ç³è¯· */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "HrManage/regular-apply"; |
| | | const { config } = useOaPage(pageKey); |
| | | import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| | |
| | | è·¯ç±ï¼/pages/oa/HrManage/transfer-apply/index |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.TRANSFER" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - 人äºç®¡ç - è°å²ç³è¯· */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "HrManage/transfer-apply"; |
| | | const { config } = useOaPage(pageKey); |
| | | import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| | |
| | | è·¯ç±ï¼/pages/oa/HrManage/work-handover/index |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - 人äºç®¡ç - å·¥ä½äº¤æ¥ */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "HrManage/work-handover"; |
| | | const { config } = useOaPage(pageKey); |
| | | import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | æ¥éå®¡æ¹æµç¨ï¼å¯æç´¢é人ï¼ç¹éå³ç¡®è®¤ï¼ |
| | | --> |
| | | <template> |
| | | <view class="flow-wrap"> |
| | | <view v-for="(item, index) in innerList" |
| | | :key="item._uid" |
| | | class="flow-node-block"> |
| | | <view class="flow-node-card"> |
| | | <view class="node-header"> |
| | | <view class="node-level-badge">{{ index + 1 }}</view> |
| | | <text class="node-level-text">第{{ levelLabel(index + 1) }}级审æ¹</text> |
| | | <view v-if="innerList.length > 1" |
| | | class="node-delete" |
| | | @click="remove(index)"> |
| | | <up-icon name="trash" |
| | | size="16" |
| | | color="#f56c6c" /> |
| | | </view> |
| | | </view> |
| | | <view class="approver-row" |
| | | @click="openPicker(index)"> |
| | | <view class="approver-avatar" |
| | | :style="{ backgroundColor: avatarColor(item.approverName) }"> |
| | | {{ (item.approverName || '+').charAt(0) }} |
| | | </view> |
| | | <view class="approver-meta"> |
| | | <text class="approver-name">{{ item.approverName || 'ç¹å»éæ©å®¡æ¹äºº' }}</text> |
| | | <text class="approver-hint">æ¯ææç´¢å§åæå·¥å·</text> |
| | | </view> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view v-if="index < innerList.length - 1" |
| | | class="flow-connector"> |
| | | <view class="flow-connector-line" /> |
| | | </view> |
| | | </view> |
| | | <view class="add-node-bar" |
| | | @click="addNode"> |
| | | <up-icon name="plus-circle" |
| | | size="18" |
| | | color="#2979ff" /> |
| | | <text>æ·»å 审æ¹çº§æ¬¡</text> |
| | | </view> |
| | | |
| | | <OaUserSearchPicker v-model:show="pickerShow" |
| | | v-model="pickerUserId" |
| | | title="鿩审æ¹äºº" |
| | | :users="userOptions" |
| | | :show-self-quick="false" |
| | | @select="onUserSelected" /> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, watch } from "vue"; |
| | | import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue"; |
| | | import { userAvatarColor } from "../../_utils/userPickerUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { type: Array, default: () => [] }, |
| | | userOptions: { type: Array, default: () => [] }, |
| | | }); |
| | | const emit = defineEmits(["update:modelValue"]); |
| | | |
| | | const innerList = ref([]); |
| | | const pickerShow = ref(false); |
| | | const pickerUserId = ref(""); |
| | | const editingIndex = ref(-1); |
| | | |
| | | function newUid() { |
| | | return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; |
| | | } |
| | | |
| | | function levelLabel(n) { |
| | | const t = ["ä¸", "äº", "ä¸", "å", "äº", "å
", "ä¸", "å
«"]; |
| | | return t[n - 1] || String(n); |
| | | } |
| | | |
| | | function avatarColor(name) { |
| | | return userAvatarColor(name); |
| | | } |
| | | |
| | | function mapIn(rows) { |
| | | return (rows || []).map((n, i) => ({ |
| | | _uid: n._uid || newUid(), |
| | | nodeOrder: n.nodeOrder ?? i + 1, |
| | | signMode: n.signMode || "countersign", |
| | | approverId: n.approverId ?? "", |
| | | approverName: n.approverName || "", |
| | | id: n.id, |
| | | templateId: n.templateId, |
| | | })); |
| | | } |
| | | |
| | | function mapOut() { |
| | | return innerList.value.map((n, i) => ({ |
| | | nodeOrder: i + 1, |
| | | signMode: n.signMode || "countersign", |
| | | approverId: n.approverId, |
| | | approverName: n.approverName, |
| | | id: n.id, |
| | | templateId: n.templateId, |
| | | })); |
| | | } |
| | | |
| | | function syncEmit() { |
| | | emit("update:modelValue", mapOut()); |
| | | } |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | v => { |
| | | innerList.value = mapIn(v); |
| | | if (!innerList.value.length) { |
| | | innerList.value = [ |
| | | { _uid: newUid(), nodeOrder: 1, signMode: "countersign", approverId: "", approverName: "" }, |
| | | ]; |
| | | } |
| | | }, |
| | | { immediate: true, deep: true } |
| | | ); |
| | | |
| | | function addNode() { |
| | | innerList.value.push({ |
| | | _uid: newUid(), |
| | | nodeOrder: innerList.value.length + 1, |
| | | signMode: "countersign", |
| | | approverId: "", |
| | | approverName: "", |
| | | }); |
| | | syncEmit(); |
| | | } |
| | | |
| | | function remove(index) { |
| | | if (innerList.value.length <= 1) { |
| | | uni.showToast({ title: "è³å°ä¿çä¸ä¸ªå®¡æ¹èç¹", icon: "none" }); |
| | | return; |
| | | } |
| | | innerList.value.splice(index, 1); |
| | | syncEmit(); |
| | | } |
| | | |
| | | function openPicker(index) { |
| | | editingIndex.value = index; |
| | | pickerUserId.value = innerList.value[index]?.approverId || ""; |
| | | pickerShow.value = true; |
| | | } |
| | | |
| | | function onUserSelected(u) { |
| | | const node = innerList.value[editingIndex.value]; |
| | | if (!node) return; |
| | | node.approverId = u.userId ?? u.id; |
| | | node.approverName = u.nickName || u.userName || ""; |
| | | syncEmit(); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .flow-node-card { |
| | | background: #f8f9fb; |
| | | border-radius: 10px; |
| | | padding: 12px; |
| | | border: 1px solid #eef0f3; |
| | | } |
| | | .node-header { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 10px; |
| | | } |
| | | .node-level-badge { |
| | | width: 22px; |
| | | height: 22px; |
| | | border-radius: 50%; |
| | | background: #2979ff; |
| | | color: #fff; |
| | | font-size: 12px; |
| | | text-align: center; |
| | | line-height: 22px; |
| | | margin-right: 8px; |
| | | } |
| | | .node-level-text { |
| | | flex: 1; |
| | | font-size: 14px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | } |
| | | .approver-row { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 10px 12px; |
| | | background: #fff; |
| | | border-radius: 8px; |
| | | } |
| | | .approver-avatar { |
| | | width: 36px; |
| | | height: 36px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | .approver-meta { |
| | | flex: 1; |
| | | margin-left: 10px; |
| | | min-width: 0; |
| | | } |
| | | .approver-name { |
| | | display: block; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | } |
| | | .approver-hint { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | margin-top: 2px; |
| | | } |
| | | .flow-connector { |
| | | display: flex; |
| | | justify-content: center; |
| | | padding: 6px 0; |
| | | } |
| | | .flow-connector-line { |
| | | width: 2px; |
| | | height: 14px; |
| | | background: #dcdfe6; |
| | | } |
| | | .add-node-bar { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 6px; |
| | | padding: 14px 0 4px; |
| | | color: #2979ff; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | æ¥éæç»åæ¡ç¼è¾ï¼åºé¨å¼¹å±ï¼ |
| | | --> |
| | | <template> |
| | | <up-popup :show="show" |
| | | mode="bottom" |
| | | round="16" |
| | | :safe-area-inset-bottom="true" |
| | | @close="close"> |
| | | <view class="detail-sheet"> |
| | | <view class="sheet-handle" /> |
| | | <view class="sheet-head"> |
| | | <text class="sheet-cancel" |
| | | @click="close">åæ¶</text> |
| | | <text class="sheet-title">{{ title }}</text> |
| | | <text class="sheet-confirm" |
| | | @click="confirm">ä¿å</text> |
| | | </view> |
| | | |
| | | <scroll-view scroll-y |
| | | class="sheet-body" |
| | | :show-scrollbar="false"> |
| | | <view class="sheet-group"> |
| | | <view class="sheet-cell sheet-cell--tap" |
| | | @click="showDatePicker = true"> |
| | | <text class="sheet-label required">åç¥¨æ¥æ</text> |
| | | <view class="sheet-value-wrap"> |
| | | <text class="sheet-value" |
| | | :class="{ placeholder: !draft.invoiceDate }"> |
| | | {{ draft.invoiceDate || 'è¯·éæ©' }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="sheet-cell sheet-cell--tap" |
| | | @click="showSubjectSheet = true"> |
| | | <text class="sheet-label required">è´¹ç¨ç§ç®</text> |
| | | <view class="sheet-value-wrap"> |
| | | <text class="sheet-value" |
| | | :class="{ placeholder: !draft.expenseSubject }"> |
| | | {{ subjectText }} |
| | | </text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="sheet-cell"> |
| | | <text class="sheet-label required">éé¢</text> |
| | | <view class="sheet-input-wrap"> |
| | | <up-input v-model="draft.amount" |
| | | type="digit" |
| | | placeholder="0.00" |
| | | border="none" |
| | | input-align="right" /> |
| | | <text class="sheet-unit">å
</text> |
| | | </view> |
| | | </view> |
| | | <view class="sheet-cell sheet-cell--col"> |
| | | <text class="sheet-label">æè¿°</text> |
| | | <view class="sheet-textarea-wrap"> |
| | | <up-textarea v-model="draft.description" |
| | | placeholder="è´¹ç¨è¯´æï¼éå¡«ï¼" |
| | | maxlength="200" |
| | | border="none" |
| | | height="64" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="showDelete" |
| | | class="sheet-delete" |
| | | @click="emit('delete')"> |
| | | å 餿¬æ¡æç» |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | |
| | | <up-action-sheet :show="showSubjectSheet" |
| | | title="è´¹ç¨ç§ç®" |
| | | :actions="subjectActions" |
| | | @select="onSubjectSelect" |
| | | @close="showSubjectSheet = false" /> |
| | | |
| | | <up-popup :show="showDatePicker" |
| | | mode="bottom" |
| | | round="16" |
| | | @close="showDatePicker = false"> |
| | | <up-datetime-picker :show="true" |
| | | v-model="datePickerTs" |
| | | mode="date" |
| | | @confirm="onDateConfirm" |
| | | @cancel="showDatePicker = false" /> |
| | | </up-popup> |
| | | </up-popup> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { expenseSubjectLabel as costSubjectLabel } from "../_utils/costReimburseUtils.js"; |
| | | import { expenseSubjectLabel as travelSubjectLabel } from "../_utils/travelReimburseUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | show: { type: Boolean, default: false }, |
| | | modelValue: { type: Object, default: () => ({}) }, |
| | | index: { type: Number, default: 0 }, |
| | | isTravel: { type: Boolean, default: true }, |
| | | subjectOptions: { type: Array, default: () => [] }, |
| | | showDelete: { type: Boolean, default: true }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:show", "update:modelValue", "confirm", "delete"]); |
| | | |
| | | const draft = reactive({ |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }); |
| | | |
| | | const showDatePicker = ref(false); |
| | | const showSubjectSheet = ref(false); |
| | | const datePickerTs = ref(Date.now()); |
| | | |
| | | const title = computed(() => `æç» ${props.index + 1}`); |
| | | |
| | | const subjectActions = computed(() => |
| | | (props.subjectOptions || []).map(x => ({ name: x.label, value: x.value })) |
| | | ); |
| | | |
| | | const subjectText = computed(() => resolveSubjectLabel(draft.expenseSubject)); |
| | | |
| | | function resolveSubjectLabel(v) { |
| | | if (!v) return "è¯·éæ©"; |
| | | const labelFn = props.isTravel ? travelSubjectLabel : costSubjectLabel; |
| | | const t = labelFn(v); |
| | | if (t && t !== "â") return t; |
| | | const hit = (props.subjectOptions || []).find(x => x.value === v || x.label === v); |
| | | return hit?.label || v; |
| | | } |
| | | |
| | | watch( |
| | | () => props.show, |
| | | v => { |
| | | if (v && props.modelValue) { |
| | | Object.assign(draft, { |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | ...JSON.parse(JSON.stringify(props.modelValue)), |
| | | }); |
| | | } |
| | | } |
| | | ); |
| | | |
| | | function close() { |
| | | emit("update:show", false); |
| | | } |
| | | |
| | | function confirm() { |
| | | if (!draft.invoiceDate) { |
| | | uni.showToast({ title: "è¯·éæ©åç¥¨æ¥æ", icon: "none" }); |
| | | return; |
| | | } |
| | | if (!draft.expenseSubject) { |
| | | uni.showToast({ title: "è¯·éæ©è´¹ç¨ç§ç®", icon: "none" }); |
| | | return; |
| | | } |
| | | if (draft.amount === "" || draft.amount == null) { |
| | | uni.showToast({ title: "请填åéé¢", icon: "none" }); |
| | | return; |
| | | } |
| | | emit("update:modelValue", { ...draft }); |
| | | emit("confirm", { ...draft }); |
| | | emit("update:show", false); |
| | | } |
| | | |
| | | function onSubjectSelect(action) { |
| | | draft.expenseSubject = action.value; |
| | | showSubjectSheet.value = false; |
| | | } |
| | | |
| | | function onDateConfirm(e) { |
| | | const ts = e?.value ?? datePickerTs.value; |
| | | draft.invoiceDate = parseTime(ts, "{y}-{m}-{d}"); |
| | | showDatePicker.value = false; |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .detail-sheet { |
| | | background: #fff; |
| | | border-radius: 16px 16px 0 0; |
| | | max-height: 85vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | .sheet-handle { |
| | | width: 36px; |
| | | height: 4px; |
| | | background: #e4e7ed; |
| | | border-radius: 2px; |
| | | margin: 8px auto 4px; |
| | | } |
| | | .sheet-head { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 8px 16px 12px; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | } |
| | | .sheet-cancel { |
| | | font-size: 15px; |
| | | color: #909399; |
| | | min-width: 48px; |
| | | } |
| | | .sheet-title { |
| | | flex: 1; |
| | | text-align: center; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | .sheet-confirm { |
| | | font-size: 15px; |
| | | color: #2979ff; |
| | | font-weight: 600; |
| | | min-width: 48px; |
| | | text-align: right; |
| | | } |
| | | .sheet-body { |
| | | max-height: 70vh; |
| | | padding-bottom: env(safe-area-inset-bottom); |
| | | } |
| | | .sheet-group { |
| | | margin: 12px 16px; |
| | | background: #f8f9fb; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | } |
| | | .sheet-cell { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 52px; |
| | | padding: 12px 14px; |
| | | background: #fff; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | &--col { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | &--tap:active { |
| | | background: #fafbfc; |
| | | } |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | .sheet-label { |
| | | width: 80px; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | flex-shrink: 0; |
| | | &.required::before { |
| | | content: "*"; |
| | | color: #f56c6c; |
| | | margin-right: 2px; |
| | | } |
| | | } |
| | | .sheet-value-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | gap: 4px; |
| | | } |
| | | .sheet-value { |
| | | font-size: 15px; |
| | | color: #303133; |
| | | &.placeholder { |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | .sheet-input-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | } |
| | | .sheet-unit { |
| | | font-size: 14px; |
| | | color: #909399; |
| | | margin-left: 4px; |
| | | } |
| | | .sheet-textarea-wrap { |
| | | width: 100%; |
| | | margin-top: 8px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | padding: 4px 8px; |
| | | } |
| | | .sheet-delete { |
| | | margin: 16px; |
| | | text-align: center; |
| | | font-size: 15px; |
| | | color: #f56c6c; |
| | | padding: 14px; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | border: 1px solid #fde2e2; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥é详æ
å±ç¤ºï¼å表详æ
/ 审æ¹è¯¦æ
å
±ç¨ï¼ |
| | | --> |
| | | <template> |
| | | <view class="rd-body"> |
| | | <!-- æ¦è¦ --> |
| | | <view class="rd-hero"> |
| | | <view class="rd-hero-top"> |
| | | <text class="rd-bill-no">{{ billNo }}</text> |
| | | <text :class="['rd-status', statusCssClass]">{{ statusText }}</text> |
| | | </view> |
| | | <text class="rd-reason">{{ reasonText }}</text> |
| | | <view class="rd-amount-row"> |
| | | <text class="rd-amount-label">ç³è¯·éé¢</text> |
| | | <text class="rd-amount">{{ amountText }}</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- ç³è¯·äºº --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">ç³è¯·äºº</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">å§å</text> |
| | | <text class="rd-value">{{ r.applicantName || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åå·¥ç¼å·</text> |
| | | <text class="rd-value">{{ r.applicantCode || r.applicantNo || "â" }}</text> |
| | | </view> |
| | | <view v-if="r.applicantDeptName || r.deptName" |
| | | class="rd-cell"> |
| | | <text class="rd-label">é¨é¨</text> |
| | | <text class="rd-value">{{ r.applicantDeptName || r.deptName }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- åºå·® / è´¹ç¨ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">{{ isTravel ? "åºå·®ä¿¡æ¯" : "è´¹ç¨ä¿¡æ¯" }}</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <template v-if="isTravel"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®å¼å§</text> |
| | | <text class="rd-value">{{ formatTime(r.travelStartTime) }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®ç»æ</text> |
| | | <text class="rd-value">{{ formatTime(r.travelEndTime) }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®å¤©æ°</text> |
| | | <text class="rd-value">{{ travelDaysText }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®å°</text> |
| | | <text class="rd-value">{{ r.departurePlace || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">ç®çå°</text> |
| | | <text class="rd-value">{{ r.destination || "â" }}</text> |
| | | </view> |
| | | </template> |
| | | <view v-else |
| | | class="rd-cell"> |
| | | <text class="rd-label">è´¹ç¨ç±»å</text> |
| | | <text class="rd-value">{{ expenseTypeText }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å·®æ
æ å --> |
| | | <view v-if="isTravel && hasTravelStandard" |
| | | class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">å·®æ
æ å</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view v-if="r.hotelStandard != null" |
| | | class="rd-cell"> |
| | | <text class="rd-label">é
åºæ å</text> |
| | | <text class="rd-value">{{ r.hotelStandard }} å
/æ</text> |
| | | </view> |
| | | <view v-if="r.hotelDays != null" |
| | | class="rd-cell"> |
| | | <text class="rd-label">ä½å®¿å¤©æ°</text> |
| | | <text class="rd-value">{{ r.hotelDays }} 天</text> |
| | | </view> |
| | | <view v-if="r.livingSubsidy != null" |
| | | class="rd-cell"> |
| | | <text class="rd-label">çæ´»è¡¥è´´</text> |
| | | <text class="rd-value">{{ r.livingSubsidy }} å
</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">æ åæ è®°</text> |
| | | <text class="rd-value">{{ r.standardTag || (r.needSpecialApproval ? "è¶
æ¯éç¹æ¹" : "卿 åå
") }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- æ¶æ¬¾ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">æ¶æ¬¾ä¿¡æ¯</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">æ¶æ¬¾äºº</text> |
| | | <text class="rd-value">{{ r.payeeName || r.payee || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">æ¶æ¬¾è´¦å·</text> |
| | | <text class="rd-value">{{ r.payeeAccount || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">弿·æ¯è¡</text> |
| | | <text class="rd-value">{{ r.payeeBank || r.bankBranch || "â" }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- æ¥éæç» --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">æ¥éæç»</text> |
| | | <text class="rd-section-count">å
± {{ detailRows.length }} æ¡</text> |
| | | </view> |
| | | <view v-if="detailRows.length" |
| | | class="rd-group"> |
| | | <view v-for="(d, idx) in detailRows" |
| | | :key="'d-' + idx" |
| | | class="rd-detail-item"> |
| | | <view class="rd-detail-head"> |
| | | <text class="rd-detail-badge">{{ idx + 1 }}</text> |
| | | <text class="rd-detail-title">{{ detailSubject(d) }}</text> |
| | | <text class="rd-detail-amount">{{ detailAmount(d) }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åç¥¨æ¥æ</text> |
| | | <text class="rd-value">{{ d.invoiceDate || "â" }}</text> |
| | | </view> |
| | | <view v-if="d.description" |
| | | class="rd-cell"> |
| | | <text class="rd-label">æè¿°</text> |
| | | <text class="rd-value">{{ d.description }}</text> |
| | | </view> |
| | | <view v-if="d.invoiceNo" |
| | | class="rd-cell"> |
| | | <text class="rd-label">å票å·</text> |
| | | <text class="rd-value">{{ d.invoiceNo }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rd-group"> |
| | | <view class="rd-empty">ææ æ¥éæç»</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éä»¶ --> |
| | | <view v-if="attachmentList.length" |
| | | class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">å票éä»¶</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view v-for="(f, i) in attachmentList" |
| | | :key="i" |
| | | class="rd-attach" |
| | | @click="openAttachment(f)"> |
| | | {{ f.name || "éä»¶" }} |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å®¡æ¹æµç¨ï¼tasksï¼ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">å®¡æ¹æµç¨</text> |
| | | <text class="rd-section-count">{{ flowNodesList.length }} 级</text> |
| | | </view> |
| | | <view v-if="flowNodesList.length" |
| | | class="rd-group"> |
| | | <view v-for="(node, nodeIndex) in flowNodesList" |
| | | :key="nodeIndex" |
| | | class="rd-flow-node"> |
| | | <view class="rd-flow-line"> |
| | | <view class="rd-flow-dot" /> |
| | | <view v-if="nodeIndex < flowNodesList.length - 1" |
| | | class="rd-flow-bar" /> |
| | | </view> |
| | | <view class="rd-flow-body"> |
| | | <text class="rd-flow-level">第{{ node.levelNo }}级 · {{ node.approveType === 'OR' ? 'æç¾' : 'ä¼ç¾' }}</text> |
| | | <view v-for="(a, ai) in node.approvers" |
| | | :key="ai" |
| | | class="rd-flow-approver"> |
| | | <view class="rd-flow-avatar" |
| | | :style="{ backgroundColor: avatarColor(a.approverName) }"> |
| | | {{ (a.approverName || "?").charAt(0) }} |
| | | </view> |
| | | <view class="rd-flow-approver-meta"> |
| | | <text class="rd-flow-name">{{ a.approverName || "â" }}</text> |
| | | <text v-if="a.taskStatus" |
| | | class="rd-flow-status">{{ taskStatusLabel(a.taskStatus) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rd-group"> |
| | | <view class="rd-empty">ææ å®¡æ¹èç¹</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 审æ¹è®°å½ï¼tasks ççï¼ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">审æ¹è®°å½</text> |
| | | <text class="rd-section-count">{{ approvalRecords.length }} æ¡</text> |
| | | </view> |
| | | <view v-if="approvalRecords.length" |
| | | class="rd-group"> |
| | | <view v-for="(rec, index) in approvalRecords" |
| | | :key="rec.id ?? index" |
| | | class="rd-record-item"> |
| | | <view class="rd-record-head"> |
| | | <text class="rd-record-operator">{{ rec.operatorName }}</text> |
| | | <text class="rd-record-tag" |
| | | :class="'rd-record-tag--' + rec.result">{{ recordLabel(rec.result) }}</text> |
| | | </view> |
| | | <text v-if="rec.time" |
| | | class="rd-record-time">{{ rec.time }}</text> |
| | | <text class="rd-record-opinion">{{ rec.opinion || "æ æè§" }}</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rd-group"> |
| | | <view class="rd-empty">ææ å®¡æ¹è®°å½</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="rd-section"> |
| | | <view class="rd-group"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">å建æ¶é´</text> |
| | | <text class="rd-value">{{ formatTime(r.createTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { isTravelReimbursementType } from "../../_utils/finReimbursementMappers.js"; |
| | | import { |
| | | billStatusCssClass, |
| | | billStatusLabel, |
| | | } from "../../_utils/finReimbursementMappers.js"; |
| | | import { expenseCategoryLabel, EXPENSE_SUBJECT_OPTIONS as COST_SUBJECTS } from "../_utils/costReimburseUtils.js"; |
| | | import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_SUBJECTS } from "../_utils/travelReimburseUtils.js"; |
| | | import { |
| | | resolveExpenseSubjectLabel, |
| | | formatDetailAmount, |
| | | } from "../_utils/expenseDetailDisplay.js"; |
| | | import { userAvatarColor } from "../../_utils/userPickerUtils.js"; |
| | | import { |
| | | mapTasksToFlowNodes, |
| | | recordActionLabel, |
| | | taskStatusText, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import config from "@/config.js"; |
| | | |
| | | const props = defineProps({ |
| | | reimburseRow: { type: Object, default: () => ({}) }, |
| | | moduleKey: { type: String, default: "" }, |
| | | }); |
| | | |
| | | const r = computed(() => props.reimburseRow || {}); |
| | | |
| | | const isTravel = computed(() => |
| | | isTravelReimbursementType(r.value.reimbursementType ?? props.moduleKey) |
| | | ); |
| | | |
| | | const billNo = computed(() => r.value.billNo || r.value.reimburseNo || "â"); |
| | | const statusText = computed(() => |
| | | billStatusLabel(r.value.billStatus ?? r.value.status) |
| | | ); |
| | | const statusCssClass = computed(() => |
| | | billStatusCssClass(r.value) |
| | | ); |
| | | const reasonText = computed( |
| | | () => r.value.reason || r.value.reimburseReason || "â" |
| | | ); |
| | | const amountText = computed(() => |
| | | r.value.applyAmount != null ? String(r.value.applyAmount) : "â" |
| | | ); |
| | | |
| | | const expenseTypeText = computed(() => |
| | | expenseCategoryLabel(r.value.expenseCategory) || r.value.expenseType || "â" |
| | | ); |
| | | |
| | | const travelDaysText = computed(() => { |
| | | const d = r.value.travelDays ?? r.value.travel?.travelDays; |
| | | return d != null ? `${d} 天` : "â"; |
| | | }); |
| | | |
| | | const hasTravelStandard = computed(() => { |
| | | const row = r.value; |
| | | return ( |
| | | row.hotelStandard != null || |
| | | row.hotelDays != null || |
| | | row.livingSubsidy != null || |
| | | row.standardTag || |
| | | row.needSpecialApproval |
| | | ); |
| | | }); |
| | | |
| | | const subjectOptions = computed(() => |
| | | isTravel.value ? TRAVEL_SUBJECTS : COST_SUBJECTS |
| | | ); |
| | | |
| | | const detailRows = computed(() => { |
| | | const list = r.value.expenseDetails || r.value.details || []; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | const attachmentList = computed(() => { |
| | | const list = |
| | | r.value.attachmentList || |
| | | r.value.storageBlobVOList || |
| | | r.value.invoiceAttachments || |
| | | []; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | const approvalRecords = computed(() => { |
| | | const list = r.value.approvalRecords || []; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | /** æµç¨å±ç¤ºä¼å
ç¨ enrichment åç flowNodesï¼æ¥èª tasksï¼ */ |
| | | const flowNodesList = computed(() => { |
| | | const row = r.value; |
| | | if (Array.isArray(row.flowNodes) && row.flowNodes.length) { |
| | | return row.flowNodes; |
| | | } |
| | | if (Array.isArray(row.tasks) && row.tasks.length) { |
| | | return mapTasksToFlowNodes(row.tasks); |
| | | } |
| | | return []; |
| | | }); |
| | | |
| | | function taskStatusLabel(status) { |
| | | return taskStatusText(status); |
| | | } |
| | | |
| | | function recordLabel(result) { |
| | | return recordActionLabel(result); |
| | | } |
| | | |
| | | function formatTime(t) { |
| | | if (!t) return "â"; |
| | | const s = parseTime(t, "{y}-{m}-{d} {h}:{i}"); |
| | | return s || String(t).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | function detailSubject(d) { |
| | | return ( |
| | | resolveExpenseSubjectLabel(d.expenseSubject || d.expenseCategory, { |
| | | isTravel: isTravel.value, |
| | | subjectOptions: subjectOptions.value, |
| | | }) || "æªéç§ç®" |
| | | ); |
| | | } |
| | | |
| | | function detailAmount(d) { |
| | | return formatDetailAmount(d.amount) || "â"; |
| | | } |
| | | |
| | | function avatarColor(name) { |
| | | return userAvatarColor(name); |
| | | } |
| | | |
| | | function resolveFileUrl(f) { |
| | | let url = f?.url || f?.downloadURL || f?.previewURL || f?.fileUrl || ""; |
| | | if (!url) return ""; |
| | | if (/^https?:\/\//i.test(url)) return url; |
| | | const base = (config.baseUrl || "").replace(/\/+$/, ""); |
| | | const path = url.startsWith("/") ? url : `/${url}`; |
| | | return `${base}${path}`; |
| | | } |
| | | |
| | | function openAttachment(f) { |
| | | const url = resolveFileUrl(f); |
| | | if (!url) { |
| | | uni.showToast({ title: "æ æ³æå¼éä»¶", icon: "none" }); |
| | | return; |
| | | } |
| | | // #ifdef H5 |
| | | window.open(url, "_blank"); |
| | | // #endif |
| | | // #ifndef H5 |
| | | uni.downloadFile({ |
| | | url, |
| | | success: res => { |
| | | if (res.statusCode === 200) { |
| | | uni.openDocument({ filePath: res.tempFilePath, showMenu: true }); |
| | | } |
| | | }, |
| | | fail: () => uni.showToast({ title: "éä»¶æå¼å¤±è´¥", icon: "none" }), |
| | | }); |
| | | // #endif |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../reimburse-detail/reimburse-detail.scss"; |
| | | </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 function expenseSubjectLabel(v) { |
| | | return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function expenseCategoryLabel(v) { |
| | | return EXPENSE_CATEGORY_OPTIONS.find(x => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function expenseTypeToCategory(expenseType) { |
| | | const t = (expenseType || "").trim(); |
| | | const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.label === t || x.value === t); |
| | | return hit?.value || "other"; |
| | | } |
| | | |
| | | export function createEmptyExpenseDetail() { |
| | | return { |
| | | id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyCostForm() { |
| | | return { |
| | | reimbursementId: undefined, |
| | | applicantId: "", |
| | | employeeNo: "", |
| | | employeeName: "", |
| | | expenseCategory: "other", |
| | | reimburseReason: "", |
| | | applyAmount: "", |
| | | payee: "", |
| | | payeeAccount: "", |
| | | bankBranch: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }], |
| | | 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"), |
| | | })); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { expenseSubjectLabel as costSubjectLabel } from "./costReimburseUtils.js"; |
| | | import { expenseSubjectLabel as travelSubjectLabel } from "./travelReimburseUtils.js"; |
| | | |
| | | /** è´¹ç¨ç§ç®å±ç¤ºï¼å
¼å®¹ value / 䏿 label / API expenseCategoryï¼ */ |
| | | export function resolveExpenseSubjectLabel(v, { isTravel = true, subjectOptions = [] } = {}) { |
| | | if (!v) return ""; |
| | | const labelFn = isTravel ? travelSubjectLabel : costSubjectLabel; |
| | | const t = labelFn(v); |
| | | if (t && t !== "â") return t; |
| | | const hit = subjectOptions.find(x => x.value === v || x.label === v); |
| | | return hit?.label || String(v); |
| | | } |
| | | |
| | | export function formatDetailAmount(amount) { |
| | | if (amount === "" || amount == null) return null; |
| | | const n = Number(amount); |
| | | if (Number.isNaN(n)) return String(amount); |
| | | return `${n} å
`; |
| | | } |
| | | |
| | | /** åè¡¨è¡æè¦ */ |
| | | export function buildExpenseDetailSummary(row, opts = {}) { |
| | | const subject = resolveExpenseSubjectLabel(row?.expenseSubject, opts) || "æªéç§ç®"; |
| | | const amount = formatDetailAmount(row?.amount); |
| | | const date = row?.invoiceDate || ""; |
| | | const desc = (row?.description || "").trim(); |
| | | const parts = []; |
| | | if (date) parts.push(date); |
| | | if (desc) parts.push(desc); |
| | | const sub = parts.length ? parts.join(" · ") : "ç¹å»è¯¦æ
å®åä¿¡æ¯"; |
| | | const incomplete = !row?.invoiceDate || !row?.expenseSubject || row?.amount === "" || row?.amount == null; |
| | | return { subject, amount: amount || "é颿ªå¡«", sub, incomplete }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { |
| | | mapApprovalRecords, |
| | | mapRecordResult, |
| | | mapTasksToFlowNodes, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | |
| | | function formatDisplayTime(val) { |
| | | if (!val) return ""; |
| | | const s = parseTime(val, "{y}-{m}-{d} {h}:{i}"); |
| | | return s || String(val).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | function taskStatusToNodeStatus(taskStatus) { |
| | | const s = String(taskStatus ?? "").toUpperCase(); |
| | | if (["APPROVED", "COMPLETED", "FINISHED", "PASSED", "AGREE"].includes(s)) { |
| | | return "finish"; |
| | | } |
| | | if (["REJECTED", "REJECT", "REFUSE", "REFUSED"].includes(s)) { |
| | | return "error"; |
| | | } |
| | | if (["PENDING", "IN_APPROVAL", "PROCESS", "PROCESSING"].includes(s)) { |
| | | return "process"; |
| | | } |
| | | return "wait"; |
| | | } |
| | | |
| | | /** storageBlobVOList â 页é¢éä»¶ */ |
| | | export function mapReimbursementAttachments(source = {}) { |
| | | const list = |
| | | source.storageBlobVOList || |
| | | source.storageBlobDTOs || |
| | | source.storageBlobDTOS || |
| | | source.attachmentList || |
| | | source.invoiceAttachments || |
| | | []; |
| | | if (!Array.isArray(list)) return []; |
| | | return list.map((b, i) => ({ |
| | | ...b, |
| | | id: b.id ?? b.blobId ?? `att_${i}`, |
| | | name: |
| | | b.fileName || |
| | | b.originalFilename || |
| | | b.originalFileName || |
| | | b.blobName || |
| | | b.name || |
| | | "éä»¶", |
| | | url: |
| | | b.url || |
| | | b.fileUrl || |
| | | b.downloadUrl || |
| | | b.downloadURL || |
| | | b.previewUrl || |
| | | b.previewURL || |
| | | b.link || |
| | | "", |
| | | })); |
| | | } |
| | | |
| | | /** 审æ¹è®°å½å¨ tasks */ |
| | | export function mapTasksToApprovalRecords(tasks) { |
| | | const list = Array.isArray(tasks) ? tasks : []; |
| | | return list |
| | | .map((t, index) => ({ |
| | | id: t.id ?? index, |
| | | operatorName: t.approverName || t.operatorName || t.createUserName || "â", |
| | | result: mapRecordResult(t.approveAction ?? t.taskStatus ?? t.status), |
| | | opinion: t.approveComment || t.comment || t.opinion || "", |
| | | time: formatDisplayTime( |
| | | t.approveTime || t.finishTime || t.updateTime || t.createTime || "" |
| | | ), |
| | | levelNo: t.levelNo ?? t.taskLevel, |
| | | })) |
| | | .sort((a, b) => { |
| | | const la = Number(a.levelNo ?? 0); |
| | | const lb = Number(b.levelNo ?? 0); |
| | | if (la !== lb) return la - lb; |
| | | return String(a.time).localeCompare(String(b.time)); |
| | | }); |
| | | } |
| | | |
| | | export function mapTasksToApprovalFlowNodes(tasks) { |
| | | const grouped = mapTasksToFlowNodes(tasks); |
| | | return grouped.map((node, i) => { |
| | | const approvers = node.approvers || []; |
| | | const statuses = approvers.map(a => |
| | | taskStatusToNodeStatus(a.taskStatus ?? a.status) |
| | | ); |
| | | let nodeStatus = "wait"; |
| | | if (statuses.includes("error")) nodeStatus = "error"; |
| | | else if (statuses.length && statuses.every(s => s === "finish")) { |
| | | nodeStatus = "finish"; |
| | | } else if (statuses.includes("process")) nodeStatus = "process"; |
| | | |
| | | const names = approvers.map(a => a.approverName).filter(Boolean).join("ã"); |
| | | const opinions = approvers |
| | | .map(a => a.approveComment) |
| | | .filter(Boolean) |
| | | .join("ï¼"); |
| | | |
| | | return { |
| | | nodeOrder: node.levelNo ?? i + 1, |
| | | levelNo: node.levelNo ?? i + 1, |
| | | approveType: node.approveType || "AND", |
| | | approveTypeLabel: node.approveType === "OR" ? "æç¾" : "ä¼ç¾", |
| | | approvers, |
| | | approverName: names || "â", |
| | | approveOpinion: opinions, |
| | | nodeStatus, |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | export function computeApprovalFlowCurrentIndex(approvalFlowNodes = []) { |
| | | const list = approvalFlowNodes || []; |
| | | const processing = list.findIndex(n => n.nodeStatus === "process"); |
| | | if (processing >= 0) return processing; |
| | | const errorIdx = list.findIndex(n => n.nodeStatus === "error"); |
| | | if (errorIdx >= 0) return errorIdx; |
| | | return list.filter(n => n.nodeStatus === "finish").length; |
| | | } |
| | | |
| | | export function applyFinReimbursementDetailEnrichment(mapped, raw = {}) { |
| | | if (!mapped || typeof mapped !== "object") return mapped; |
| | | const source = { ...raw, ...mapped }; |
| | | const tasks = Array.isArray(source.tasks) ? source.tasks : []; |
| | | const attachments = mapReimbursementAttachments(source); |
| | | const approvalRecords = tasks.length |
| | | ? mapTasksToApprovalRecords(tasks) |
| | | : mapApprovalRecords(source.records || source.approvalRecords); |
| | | const approvalFlowNodes = tasks.length |
| | | ? mapTasksToApprovalFlowNodes(tasks) |
| | | : mapped.approvalFlowNodes || []; |
| | | const flowNodes = tasks.length |
| | | ? mapTasksToFlowNodes(tasks) |
| | | : mapped.flowNodes || mapped.nodes || []; |
| | | |
| | | return { |
| | | ...mapped, |
| | | tasks, |
| | | storageBlobVOList: attachments, |
| | | attachmentList: attachments, |
| | | invoiceAttachments: attachments, |
| | | approvalRecords, |
| | | approvalFlowNodes, |
| | | currentNodeIndex: computeApprovalFlowCurrentIndex(approvalFlowNodes), |
| | | rejectReason: |
| | | approvalRecords.find(r => r.result === "rejected")?.opinion || |
| | | source.rejectReason || |
| | | "", |
| | | flowNodes, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 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; |
| | | return Math.max(1, Math.ceil(t1.diff(t0, "day", true))); |
| | | } |
| | | |
| | | export function createEmptyExpenseDetail() { |
| | | return { |
| | | id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyTravelForm() { |
| | | return { |
| | | reimbursementId: undefined, |
| | | applicantId: "", |
| | | employeeNo: "", |
| | | employeeName: "", |
| | | reimburseReason: "", |
| | | travelStartTime: "", |
| | | travelEndTime: "", |
| | | travelDays: undefined, |
| | | departurePlace: "", |
| | | destination: "", |
| | | hotelStandard: undefined, |
| | | hotelDays: undefined, |
| | | livingSubsidy: undefined, |
| | | transportSubsidy: undefined, |
| | | lodgingLimit: undefined, |
| | | applyAmount: "", |
| | | payee: "", |
| | | payeeAccount: "", |
| | | payeeBank: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }], |
| | | needSpecialApproval: false, |
| | | deptId: "", |
| | | deptName: "", |
| | | travelTier: "tier3", |
| | | standardTag: "", |
| | | }; |
| | | } |
| | |
| | | <!-- |
| | | OA / æ¥é管ç / è´¹ç¨æ¥é |
| | | è·¯ç±ï¼/pages/oa/ReimburseManage/cost-reimburse/index |
| | | OA / æ¥é管ç / è´¹ç¨æ¥éï¼/finReimbursement/listPageï¼reimbursementType=2ï¼ |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.COST_REIMBURSE" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - æ¥é管ç - è´¹ç¨æ¥é */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "ReimburseManage/cost-reimburse"; |
| | | const { config } = useOaPage(pageKey); |
| | | import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥é详æ
页 |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page reimburse-detail-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <view v-if="loading" |
| | | class="rd-loading-wrap"> |
| | | <up-loading-icon mode="circle" /> |
| | | <text class="rd-loading-text">å è½½ä¸...</text> |
| | | </view> |
| | | |
| | | <scroll-view v-else-if="reimburseRow" |
| | | class="oa-detail-scroll reimburse-detail-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <ReimburseInstanceDetailBody :reimburse-row="reimburseRow" |
| | | :module-key="moduleKey" /> |
| | | </scroll-view> |
| | | |
| | | <view v-else |
| | | class="oa-empty"> |
| | | <up-empty mode="data" |
| | | text="æªè·åå°æ¥éæ°æ®" /> |
| | | </view> |
| | | |
| | | <view v-if="reimburseRow && canEdit" |
| | | class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | @click="goBack">è¿å</text> |
| | | <text class="oa-footer-btn btn-primary" |
| | | @click="goEdit">ä¿®æ¹</text> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ReimburseInstanceDetailBody from "../_components/ReimburseInstanceDetailBody.vue"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | canEditReimbursementRow, |
| | | fetchFinReimbursementListItemDetail, |
| | | resolveReimbursementDeleteId, |
| | | } from "../../_utils/finReimbursementMappers.js"; |
| | | |
| | | const moduleKey = ref(""); |
| | | const reimbursementId = ref(""); |
| | | const reimburseRow = ref(null); |
| | | const loading = ref(false); |
| | | |
| | | const pageTitle = computed( |
| | | () => `${getApprovalModuleConfig(moduleKey.value)?.label || "æ¥é"}详æ
` |
| | | ); |
| | | |
| | | const canEdit = computed(() => |
| | | reimburseRow.value ? canEditReimbursementRow(reimburseRow.value) : false |
| | | ); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const goEdit = () => { |
| | | const rid = resolveReimbursementDeleteId(reimburseRow.value); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${moduleKey.value}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | }; |
| | | |
| | | onLoad(async options => { |
| | | moduleKey.value = options?.moduleKey || ""; |
| | | reimbursementId.value = options?.reimbursementId || ""; |
| | | if (!moduleKey.value || !reimbursementId.value) { |
| | | uni.showToast({ title: "åæ°ä¸å®æ´", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | reimburseRow.value = await fetchFinReimbursementListItemDetail( |
| | | { reimbursementId: reimbursementId.value }, |
| | | moduleKey.value |
| | | ); |
| | | if (reimburseRow.value?.moduleKey) { |
| | | moduleKey.value = reimburseRow.value.moduleKey; |
| | | } |
| | | } catch { |
| | | uni.showToast({ title: "å 载详æ
失败", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | @import "./reimburse-detail.scss"; |
| | | |
| | | .rd-loading-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 80px 0; |
| | | } |
| | | .rd-loading-text { |
| | | margin-top: 12px; |
| | | font-size: 14px; |
| | | color: #909399; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | .reimburse-detail-page { |
| | | min-height: 100vh; |
| | | background: #f2f4f7; |
| | | } |
| | | |
| | | .reimburse-detail-scroll { |
| | | padding-bottom: calc(72px + env(safe-area-inset-bottom)); |
| | | } |
| | | |
| | | .rd-hero { |
| | | margin: 12px 16px 0; |
| | | padding: 16px; |
| | | background: linear-gradient(135deg, #f0f7ff 0%, #fff 55%); |
| | | border-radius: 12px; |
| | | box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); |
| | | border: 1px solid #e8f0fe; |
| | | } |
| | | |
| | | .rd-hero-top { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .rd-bill-no { |
| | | font-size: 13px; |
| | | color: #8c8c8c; |
| | | flex: 1; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .rd-status { |
| | | flex-shrink: 0; |
| | | font-size: 11px; |
| | | padding: 5px 8px; |
| | | border-radius: 4px; |
| | | font-weight: 500; |
| | | |
| | | &.status-pending { |
| | | color: #d46b08; |
| | | background: #fff7e6; |
| | | } |
| | | &.status-approved { |
| | | color: #389e0d; |
| | | background: #f6ffed; |
| | | } |
| | | &.status-rejected { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | } |
| | | &.status-draft { |
| | | color: #595959; |
| | | background: #f5f5f5; |
| | | } |
| | | &.status-cancelled { |
| | | color: #8c8c8c; |
| | | background: #fafafa; |
| | | } |
| | | } |
| | | |
| | | .rd-reason { |
| | | display: block; |
| | | margin-top: 10px; |
| | | font-size: 17px; |
| | | font-weight: 600; |
| | | color: #1a1a1a; |
| | | line-height: 1.45; |
| | | } |
| | | |
| | | .rd-amount-row { |
| | | display: flex; |
| | | align-items: baseline; |
| | | justify-content: space-between; |
| | | margin-top: 14px; |
| | | padding-top: 12px; |
| | | border-top: 1px dashed #e8ecf0; |
| | | } |
| | | |
| | | .rd-amount-label { |
| | | font-size: 14px; |
| | | color: #8c8c8c; |
| | | } |
| | | |
| | | .rd-amount { |
| | | font-size: 22px; |
| | | font-weight: 700; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .rd-section { |
| | | margin: 12px 16px 0; |
| | | } |
| | | |
| | | .rd-section-hd { |
| | | padding: 4px 4px 8px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | } |
| | | |
| | | .rd-section-title { |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: #909399; |
| | | } |
| | | |
| | | .rd-section-count { |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | } |
| | | |
| | | .rd-group { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04); |
| | | } |
| | | |
| | | .rd-cell { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | padding: 13px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | font-size: 14px; |
| | | line-height: 1.45; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-label { |
| | | width: 88px; |
| | | flex-shrink: 0; |
| | | color: #8c8c8c; |
| | | } |
| | | |
| | | .rd-value { |
| | | flex: 1; |
| | | color: #303133; |
| | | text-align: right; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .rd-detail-item { |
| | | padding: 14px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-detail-head { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .rd-detail-badge { |
| | | width: 24px; |
| | | height: 24px; |
| | | border-radius: 6px; |
| | | background: #ecf5ff; |
| | | color: #2979ff; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | text-align: center; |
| | | line-height: 24px; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .rd-detail-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-detail-amount { |
| | | margin-left: auto; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .rd-flow-node { |
| | | display: flex; |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-flow-line { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | margin-right: 12px; |
| | | width: 20px; |
| | | } |
| | | |
| | | .rd-flow-dot { |
| | | width: 10px; |
| | | height: 10px; |
| | | border-radius: 50%; |
| | | background: #2979ff; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rd-flow-bar { |
| | | flex: 1; |
| | | width: 2px; |
| | | min-height: 20px; |
| | | background: #e4e7ed; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .rd-flow-body { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rd-flow-level { |
| | | font-size: 14px; |
| | | font-weight: 500; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-flow-type { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .rd-flow-approver { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .rd-flow-avatar { |
| | | width: 28px; |
| | | height: 28px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .rd-flow-approver-meta { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rd-flow-name { |
| | | display: block; |
| | | font-size: 14px; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-flow-status { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .rd-record-item { |
| | | padding: 14px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-record-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .rd-record-operator { |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-record-tag { |
| | | font-size: 11px; |
| | | padding: 2px 8px; |
| | | border-radius: 4px; |
| | | flex-shrink: 0; |
| | | |
| | | &--approved { |
| | | color: #389e0d; |
| | | background: #f6ffed; |
| | | } |
| | | &--rejected { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | } |
| | | &--pending { |
| | | color: #d46b08; |
| | | background: #fff7e6; |
| | | } |
| | | } |
| | | |
| | | .rd-record-time { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .rd-record-opinion { |
| | | display: block; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | margin-top: 6px; |
| | | line-height: 1.45; |
| | | } |
| | | |
| | | .rd-empty { |
| | | padding: 20px; |
| | | text-align: center; |
| | | font-size: 13px; |
| | | color: #c0c4cc; |
| | | } |
| | | |
| | | .rd-attach { |
| | | padding: 12px 16px; |
| | | font-size: 14px; |
| | | color: #2979ff; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥éæ°å¢/ç¼è¾ï¼ä¸ Web åæ®µä¸è´ï¼ç§»å¨ç«¯ä¼åé人/å¸å±ï¼ |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page reimburse-form-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | <scroll-view class="oa-detail-scroll reimburse-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <view v-if="loading" |
| | | class="rf-loading">å è½½ä¸...</view> |
| | | |
| | | <view v-else> |
| | | <!-- ç³è¯·äºº --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">ç³è¯·äºº</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-applicant-card" |
| | | :class="{ 'is-empty': !form.applicantId }" |
| | | @click="showApplicantPicker = true"> |
| | | <view class="rf-applicant-avatar" |
| | | :style="{ backgroundColor: applicantAvatarColor }"> |
| | | {{ (form.employeeName || 'é').charAt(0) }} |
| | | </view> |
| | | <view class="rf-applicant-meta"> |
| | | <text class="rf-applicant-name">{{ form.employeeName || 'è¯·éæ©åå·¥' }}</text> |
| | | <text class="rf-applicant-sub">{{ applicantDisplaySub }}</text> |
| | | </view> |
| | | <text class="rf-applicant-action">{{ form.applicantId ? 'æ´æ¢' : 'éæ©' }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- åºæ¬ä¿¡æ¯ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">åºæ¬ä¿¡æ¯</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-cell rf-cell--col"> |
| | | <text class="rf-label required">æ¥éåå </text> |
| | | <view class="rf-textarea-wrap"> |
| | | <up-textarea v-model="form.reimburseReason" |
| | | placeholder="请填ååºå·®åæ¥éåå " |
| | | maxlength="2000" |
| | | border="none" |
| | | height="80" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <template v-if="isTravel"> |
| | | <view class="rf-cell rf-cell--tap" |
| | | @click="openDatePicker('travelStartTime')"> |
| | | <text class="rf-label required">åºå·®å¼å§</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value" |
| | | :class="{ placeholder: !form.travelStartTime }"> |
| | | {{ form.travelStartTime || 'è¯·éæ©' }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell rf-cell--tap" |
| | | @click="openDatePicker('travelEndTime')"> |
| | | <text class="rf-label required">åºå·®ç»æ</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value" |
| | | :class="{ placeholder: !form.travelEndTime }"> |
| | | {{ form.travelEndTime || 'è¯·éæ©' }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">åºå·®å¤©æ°</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value">{{ travelDaysDisplay || 'â' }}</text> |
| | | <text class="rf-value" |
| | | style="color:#909399;margin-left:4px">天</text> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">åºå·®å°</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.departurePlace" |
| | | placeholder="åºååå¸" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">ç®çå°</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.destination" |
| | | placeholder="ç®çåå¸" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <template v-else> |
| | | <view class="rf-cell rf-cell--tap" |
| | | @click="showCategorySheet = true"> |
| | | <text class="rf-label required">è´¹ç¨ç±»å</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value" |
| | | :class="{ placeholder: !form.expenseCategory }">{{ categoryLabel }}</text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-chips"> |
| | | <text v-for="cat in quickCategories" |
| | | :key="cat.value" |
| | | class="rf-chip" |
| | | :class="{ active: form.expenseCategory === cat.value }" |
| | | @click="applyTemplate(cat.value)">{{ cat.label }}</text> |
| | | </view> |
| | | </template> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å·®æ
æ å --> |
| | | <view v-if="isTravel" |
| | | class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">å·®æ
æ å</text> |
| | | <text class="rf-section-extra">{{ travelTierLabel }}</text> |
| | | </view> |
| | | <view v-if="overBudgetWarnings.length" |
| | | class="rf-warn-box"> |
| | | <text v-for="(w, i) in overBudgetWarnings" |
| | | :key="i" |
| | | class="rf-warn-line">{{ w }}</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">é
åºæ å</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.hotelStandard" |
| | | type="digit" |
| | | placeholder="å
/æ" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">ä½å®¿å¤©æ°</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.hotelDays" |
| | | type="number" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">çæ´»è¡¥è´´</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.livingSubsidy" |
| | | type="digit" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">交é补贴</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value">建议 {{ suggestedTransportSubsidy }} å
</text> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">ä½å®¿éé¢</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value">建议 {{ suggestedHotelLimit }} å
</text> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">ç¹æ¹æ è®°</text> |
| | | <text class="rf-tag" |
| | | :class="form.needSpecialApproval ? 'rf-tag--danger' : 'rf-tag--ok'"> |
| | | {{ form.needSpecialApproval ? 'è¶
æ¯éç¹æ¹' : '卿 åå
' }} |
| | | </text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éé¢ä¸æ¶æ¬¾ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">éé¢ä¸æ¶æ¬¾</text> |
| | | <text class="rf-section-extra" |
| | | @click="syncApplyAmountFromDetails">ææç» {{ detailTotalAmount }} å
</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">ç³è¯·éé¢</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.applyAmount" |
| | | type="digit" |
| | | placeholder="å
" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">æ¶æ¬¾äºº</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.payee" |
| | | placeholder="æ¶æ¬¾äºº" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">æ¶æ¬¾è´¦å·</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.payeeAccount" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">弿·æ¯è¡</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-if="isTravel" |
| | | v-model="form.payeeBank" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | input-align="right" /> |
| | | <up-input v-else |
| | | v-model="form.bankBranch" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- æ¥éæç»ï¼å表æè¦ + 详æ
æé® --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">æ¥éæç»</text> |
| | | <text class="rf-section-extra" |
| | | @click="addAndOpenDetail">+ æ°å¢</text> |
| | | </view> |
| | | <view class="rf-group" |
| | | v-if="form.expenseDetails.length"> |
| | | <view v-for="(row, idx) in form.expenseDetails" |
| | | :key="row.id || idx" |
| | | class="rf-detail-row" |
| | | :class="{ 'rf-detail-row--warn': detailSummary(row).incomplete }" |
| | | @click="openDetailEditor(idx)"> |
| | | <view class="rf-detail-index">{{ idx + 1 }}</view> |
| | | <view class="rf-detail-body"> |
| | | <view class="rf-detail-line1"> |
| | | <text class="rf-detail-subject">{{ detailSummary(row).subject }}</text> |
| | | <text class="rf-detail-amount">{{ detailSummary(row).amount }}</text> |
| | | </view> |
| | | <text class="rf-detail-line2">{{ detailSummary(row).sub }}</text> |
| | | </view> |
| | | <text class="rf-detail-action" |
| | | @click.stop="openDetailEditor(idx)">详æ
</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rf-group"> |
| | | <view class="rf-empty" |
| | | @click="addAndOpenDetail">ç¹å»æ·»å æ¥éæç»</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éä»¶ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">éä»¶ï¼å票ï¼</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view v-for="(f, i) in form.attachmentList" |
| | | :key="i" |
| | | class="rf-attach-item"> |
| | | <text>{{ f.name || 'éä»¶' }}</text> |
| | | <text class="rf-detail-del" |
| | | @click="removeAttachment(i)">å é¤</text> |
| | | </view> |
| | | <view class="rf-upload-zone" |
| | | @click="chooseAttachment"> |
| | | <up-icon name="plus-circle" |
| | | size="22" |
| | | color="#2979ff" /> |
| | | <text>ä¸ä¼ å票/éä»¶</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å®¡æ¹æµç¨ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">å®¡æ¹æµç¨</text> |
| | | </view> |
| | | <view class="rf-group" |
| | | style="padding:12px"> |
| | | <ReimburseApprovalFlowEditor v-model="form.approvalFlowNodes" |
| | | :user-options="flowUserOptions" /> |
| | | <text class="rf-hint-row">æ¯çº§é¡»æå®å®¡æ¹äººï¼æ¯ææç´¢å§åæå·¥å·</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | @click="goBack">åæ¶</text> |
| | | <text class="oa-footer-btn btn-primary" |
| | | :class="{ 'is-disabled': submitting }" |
| | | @click="onSubmit">æäº¤</text> |
| | | </view> |
| | | |
| | | <OaUserSearchPicker v-model:show="showApplicantPicker" |
| | | v-model="form.applicantId" |
| | | title="éæ©ç³è¯·äºº" |
| | | :users="flowUserOptions" |
| | | @select="onApplicantPicked" /> |
| | | |
| | | <up-action-sheet :show="showCategorySheet" |
| | | title="è´¹ç¨ç±»å" |
| | | :actions="categoryActions" |
| | | @select="onCategorySelect" |
| | | @close="showCategorySheet = false" /> |
| | | |
| | | <ReimburseExpenseDetailSheet v-model:show="showDetailSheet" |
| | | v-model="detailDraft" |
| | | :index="editingDetailIndex" |
| | | :is-travel="isTravel" |
| | | :subject-options="expenseSubjectOptions" |
| | | @confirm="onDetailSheetConfirm" |
| | | @delete="onDetailSheetDelete" /> |
| | | |
| | | <up-popup :show="showDatePicker" |
| | | mode="bottom" |
| | | round="16" |
| | | @close="showDatePicker = false"> |
| | | <up-datetime-picker :show="true" |
| | | v-model="datePickerTs" |
| | | mode="datetime" |
| | | @confirm="onDateConfirm" |
| | | @cancel="showDatePicker = false" /> |
| | | </up-popup> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue"; |
| | | import ReimburseExpenseDetailSheet from "../_components/ReimburseExpenseDetailSheet.vue"; |
| | | import config from "@/config.js"; |
| | | import { getToken } from "@/utils/auth"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { consumeReimburseEditFromApprove } from "../../_utils/reimburseApproveBridge.js"; |
| | | import { EXPENSE_CATEGORY_OPTIONS } from "../_utils/costReimburseUtils.js"; |
| | | import { buildExpenseDetailSummary } from "../_utils/expenseDetailDisplay.js"; |
| | | import ReimburseApprovalFlowEditor from "../_components/ReimburseApprovalFlowEditor.vue"; |
| | | import { useFinReimburseForm } from "./useFinReimburseForm.js"; |
| | | |
| | | const moduleKey = ref(""); |
| | | const mode = ref("add"); |
| | | const reimbursementId = ref(""); |
| | | |
| | | const { |
| | | form, |
| | | isTravel, |
| | | submitting, |
| | | loading, |
| | | flowUserOptions, |
| | | travelDaysDisplay, |
| | | travelTierLabel, |
| | | suggestedTransportSubsidy, |
| | | suggestedHotelLimit, |
| | | detailTotalAmount, |
| | | overBudgetWarnings, |
| | | expenseSubjectOptions, |
| | | categoryActions, |
| | | categoryLabel, |
| | | showApplicantPicker, |
| | | applicantDisplaySub, |
| | | applicantAvatarColor, |
| | | showCategorySheet, |
| | | loadUserPool, |
| | | onApplicantPicked, |
| | | recalcTravelStandards, |
| | | syncApplyAmountFromDetails, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | applyTemplate, |
| | | initForm, |
| | | loadEdit, |
| | | submitForm, |
| | | } = useFinReimburseForm(moduleKey, mode); |
| | | |
| | | const showDatePicker = ref(false); |
| | | const datePickerField = ref(""); |
| | | const datePickerTs = ref(Date.now()); |
| | | |
| | | const showDetailSheet = ref(false); |
| | | const editingDetailIndex = ref(0); |
| | | const detailDraft = reactive({ |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }); |
| | | |
| | | const quickCategories = EXPENSE_CATEGORY_OPTIONS.slice(0, 4); |
| | | |
| | | const pageTitle = computed(() => { |
| | | const label = getApprovalModuleConfig(moduleKey.value)?.label || "æ¥é"; |
| | | return mode.value === "edit" ? `ç¼è¾${label}` : `æ°å¢${label}`; |
| | | }); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | function detailSummary(row) { |
| | | return buildExpenseDetailSummary(row, { |
| | | isTravel: isTravel.value, |
| | | subjectOptions: expenseSubjectOptions.value, |
| | | }); |
| | | } |
| | | |
| | | function openDetailEditor(idx) { |
| | | editingDetailIndex.value = idx; |
| | | const row = form.expenseDetails[idx]; |
| | | if (!row) return; |
| | | Object.assign(detailDraft, JSON.parse(JSON.stringify(row))); |
| | | showDetailSheet.value = true; |
| | | } |
| | | |
| | | function addAndOpenDetail() { |
| | | addExpenseDetail(); |
| | | openDetailEditor(form.expenseDetails.length - 1); |
| | | } |
| | | |
| | | function onDetailSheetConfirm(data) { |
| | | const idx = editingDetailIndex.value; |
| | | if (form.expenseDetails[idx]) { |
| | | Object.assign(form.expenseDetails[idx], data); |
| | | } |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function onDetailSheetDelete() { |
| | | const idx = editingDetailIndex.value; |
| | | removeExpenseDetail(idx); |
| | | showDetailSheet.value = false; |
| | | } |
| | | |
| | | function onCategorySelect(action) { |
| | | form.expenseCategory = action.value; |
| | | applyTemplate(action.value); |
| | | showCategorySheet.value = false; |
| | | } |
| | | |
| | | function openDatePicker(field) { |
| | | datePickerField.value = field; |
| | | detailDateIndex.value = -1; |
| | | datePickerTs.value = Date.now(); |
| | | showDatePicker.value = true; |
| | | } |
| | | |
| | | function onDateConfirm(e) { |
| | | const ts = e?.value ?? datePickerTs.value; |
| | | if (datePickerField.value) { |
| | | form[datePickerField.value] = parseTime(ts, "{y}-{m}-{d} {h}:{i}:{s}"); |
| | | recalcTravelStandards(); |
| | | } |
| | | showDatePicker.value = false; |
| | | } |
| | | |
| | | function chooseAttachment() { |
| | | uni.chooseImage({ |
| | | count: 9, |
| | | success: res => { |
| | | (res.tempFilePaths || []).forEach(path => uploadOne(path)); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | function uploadOne(filePath) { |
| | | uni.uploadFile({ |
| | | url: `${config.baseUrl}/file/upload`, |
| | | filePath, |
| | | name: "file", |
| | | header: { Authorization: "Bearer " + getToken() }, |
| | | success: res => { |
| | | try { |
| | | const data = JSON.parse(res.data || "{}"); |
| | | const url = data.url || data.data?.url || ""; |
| | | const name = data.originalFilename || data.fileName || "éä»¶"; |
| | | if (!form.attachmentList) form.attachmentList = []; |
| | | form.attachmentList.push({ name, url }); |
| | | } catch { |
| | | uni.showToast({ title: "ä¸ä¼ è§£æå¤±è´¥", icon: "none" }); |
| | | } |
| | | }, |
| | | fail: () => uni.showToast({ title: "ä¸ä¼ 失败", icon: "none" }), |
| | | }); |
| | | } |
| | | |
| | | function removeAttachment(i) { |
| | | form.attachmentList.splice(i, 1); |
| | | } |
| | | |
| | | async function onSubmit() { |
| | | const ok = await submitForm(); |
| | | if (ok) setTimeout(goBack, 400); |
| | | } |
| | | |
| | | onLoad(async options => { |
| | | moduleKey.value = options?.moduleKey || ""; |
| | | mode.value = options?.mode === "edit" ? "edit" : "add"; |
| | | reimbursementId.value = options?.reimbursementId || ""; |
| | | const fromApprove = consumeReimburseEditFromApprove(); |
| | | if (fromApprove?.moduleKey) { |
| | | moduleKey.value = fromApprove.moduleKey; |
| | | mode.value = "edit"; |
| | | reimbursementId.value = String(fromApprove.reimbursementId ?? ""); |
| | | } |
| | | if (!moduleKey.value) { |
| | | uni.showToast({ title: "ç¼ºå°æ¨¡åç±»å", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | await loadUserPool(); |
| | | await initForm(); |
| | | if (mode.value === "edit" && reimbursementId.value) { |
| | | try { |
| | | await loadEdit(reimbursementId.value); |
| | | } catch { |
| | | uni.showToast({ title: "å 载失败", icon: "none" }); |
| | | } |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | @import "./reimburse-form.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | .reimburse-form-page { |
| | | min-height: 100vh; |
| | | background: #f2f4f7; |
| | | } |
| | | |
| | | .reimburse-scroll { |
| | | padding-bottom: calc(80px + env(safe-area-inset-bottom)); |
| | | } |
| | | |
| | | .rf-section { |
| | | margin: 12px 16px 0; |
| | | } |
| | | |
| | | .rf-section-hd { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 4px 4px 8px; |
| | | } |
| | | |
| | | .rf-section-title { |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: #909399; |
| | | letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .rf-section-extra { |
| | | font-size: 13px; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .rf-group { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); |
| | | } |
| | | |
| | | .rf-applicant-card { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 16px; |
| | | background: linear-gradient(135deg, #f8fbff 0%, #fff 60%); |
| | | border-bottom: 1px solid #f0f2f5; |
| | | |
| | | &.is-empty { |
| | | background: #fff; |
| | | } |
| | | } |
| | | |
| | | .rf-applicant-avatar { |
| | | width: 48px; |
| | | height: 48px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-applicant-meta { |
| | | flex: 1; |
| | | margin-left: 12px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rf-applicant-name { |
| | | font-size: 17px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rf-applicant-sub { |
| | | font-size: 13px; |
| | | color: #909399; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .rf-applicant-action { |
| | | font-size: 14px; |
| | | color: #2979ff; |
| | | padding: 6px 12px; |
| | | background: #ecf5ff; |
| | | border-radius: 16px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-cell { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 52px; |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | &--tap:active { |
| | | background: #f9fafb; |
| | | } |
| | | |
| | | &--col { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | min-height: auto; |
| | | padding-bottom: 14px; |
| | | } |
| | | } |
| | | |
| | | .rf-label { |
| | | width: 88px; |
| | | flex-shrink: 0; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | |
| | | &.required::before { |
| | | content: "*"; |
| | | color: #f56c6c; |
| | | margin-right: 2px; |
| | | } |
| | | } |
| | | |
| | | .rf-value-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | min-width: 0; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .rf-value { |
| | | font-size: 15px; |
| | | color: #303133; |
| | | text-align: right; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | |
| | | &.placeholder { |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | |
| | | .rf-input-body { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rf-textarea-wrap { |
| | | width: 100%; |
| | | margin-top: 8px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | padding: 4px 8px; |
| | | } |
| | | |
| | | .rf-inline-input { |
| | | text-align: right; |
| | | font-size: 15px; |
| | | } |
| | | |
| | | .rf-hint-row { |
| | | padding: 8px 16px 12px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .rf-warn-box { |
| | | margin: 0 16px 8px; |
| | | padding: 10px 12px; |
| | | background: #fdf6ec; |
| | | border-radius: 8px; |
| | | border-left: 3px solid #e6a23c; |
| | | } |
| | | |
| | | .rf-warn-line { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #e6a23c; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .rf-tag { |
| | | font-size: 13px; |
| | | padding: 4px 10px; |
| | | border-radius: 4px; |
| | | &--ok { |
| | | color: #67c23a; |
| | | background: #f0f9eb; |
| | | } |
| | | &--danger { |
| | | color: #f56c6c; |
| | | background: #fef0f0; |
| | | } |
| | | } |
| | | |
| | | .rf-chips { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | padding: 0 16px 14px; |
| | | } |
| | | |
| | | .rf-chip { |
| | | font-size: 13px; |
| | | padding: 6px 14px; |
| | | background: #f5f7fa; |
| | | color: #606266; |
| | | border-radius: 20px; |
| | | border: 1px solid #ebeef5; |
| | | |
| | | &.active { |
| | | background: #ecf5ff; |
| | | color: #2979ff; |
| | | border-color: #b3d8ff; |
| | | } |
| | | } |
| | | |
| | | .rf-detail-row { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 14px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | min-height: 64px; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | &:active { |
| | | background: #f9fafb; |
| | | } |
| | | |
| | | &--warn .rf-detail-subject { |
| | | color: #e6a23c; |
| | | } |
| | | } |
| | | |
| | | .rf-detail-index { |
| | | width: 28px; |
| | | height: 28px; |
| | | border-radius: 8px; |
| | | background: #ecf5ff; |
| | | color: #2979ff; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-detail-body { |
| | | flex: 1; |
| | | margin: 0 10px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rf-detail-line1 { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .rf-detail-subject { |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | color: #303133; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | flex: 1; |
| | | } |
| | | |
| | | .rf-detail-amount { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #2979ff; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-detail-line2 { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 4px; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .rf-detail-action { |
| | | flex-shrink: 0; |
| | | font-size: 14px; |
| | | color: #2979ff; |
| | | padding: 6px 12px; |
| | | background: #ecf5ff; |
| | | border-radius: 16px; |
| | | border: 1px solid #d9ecff; |
| | | } |
| | | |
| | | .rf-detail-del { |
| | | font-size: 13px; |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .rf-upload-zone { |
| | | margin: 0 16px 14px; |
| | | padding: 20px; |
| | | border: 1px dashed #c0c4cc; |
| | | border-radius: 10px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | gap: 6px; |
| | | color: #2979ff; |
| | | font-size: 14px; |
| | | background: #fafbfc; |
| | | } |
| | | |
| | | .rf-attach-item { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 10px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .rf-link { |
| | | font-size: 13px; |
| | | color: #2979ff; |
| | | padding: 4px 0; |
| | | } |
| | | |
| | | .rf-empty { |
| | | text-align: center; |
| | | padding: 20px; |
| | | color: #c0c4cc; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .rf-loading { |
| | | padding: 60px; |
| | | text-align: center; |
| | | color: #909399; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { persistFinReimbursement } from "@/api/oa/finReimbursement.js"; |
| | | import { |
| | | isActiveUser, |
| | | unwrapUserList, |
| | | userAvatarColor, |
| | | userSelectLabel, |
| | | userSubLabel, |
| | | } from "../../_utils/userPickerUtils.js"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | buildCostReimbursementSaveDto, |
| | | buildTravelReimbursementSaveDto, |
| | | fetchFinReimbursementFormDetail, |
| | | getReimbursementTypeByModuleKey, |
| | | validateReimbursementPersistDto, |
| | | } from "../../_utils/finReimbursementMappers.js"; |
| | | import { |
| | | applyCategoryTemplate, |
| | | createEmptyCostForm, |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | EXPENSE_SUBJECT_OPTIONS as COST_SUBJECT_OPTIONS, |
| | | createEmptyExpenseDetail as createCostDetail, |
| | | } from "../_utils/costReimburseUtils.js"; |
| | | import { |
| | | computeTravelDays, |
| | | createEmptyExpenseDetail, |
| | | createEmptyTravelForm, |
| | | detectTravelTier, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | getTravelStandardByTier, |
| | | } from "../_utils/travelReimburseUtils.js"; |
| | | |
| | | const userStore = useUserStore(); |
| | | |
| | | 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 (Number(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; |
| | | } |
| | | |
| | | export function useFinReimburseForm(moduleKeyRef, modeRef) { |
| | | const isTravel = computed( |
| | | () => moduleKeyRef.value === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE |
| | | ); |
| | | |
| | | const form = reactive( |
| | | moduleKeyRef.value === APPROVAL_MODULE_KEYS.COST_REIMBURSE |
| | | ? createEmptyCostForm() |
| | | : createEmptyTravelForm() |
| | | ); |
| | | |
| | | const submitting = ref(false); |
| | | const loading = ref(false); |
| | | const allUsersCache = ref([]); |
| | | const showApplicantPicker = ref(false); |
| | | const applicantDisplaySub = computed(() => { |
| | | if (!form.applicantId) return "ç¹å»éæ©ç³è¯·äºº"; |
| | | const u = userById(form.applicantId); |
| | | if (u) return userSubLabel(u) || form.employeeNo || ""; |
| | | return form.employeeNo ? `å·¥å· ${form.employeeNo}` : ""; |
| | | }); |
| | | const applicantAvatarColor = computed(() => |
| | | userAvatarColor(form.employeeName || form.employeeNo || "") |
| | | ); |
| | | const showCategorySheet = ref(false); |
| | | const showSubjectSheet = ref(false); |
| | | const editingDetailIndex = ref(-1); |
| | | const pickApplicantId = ref(""); |
| | | const pickCategoryValue = ref(""); |
| | | const pickSubjectValue = ref(""); |
| | | |
| | | const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser)); |
| | | |
| | | const travelDaysDisplay = computed(() => { |
| | | if (!isTravel.value) return ""; |
| | | const d = computeTravelDays(form.travelStartTime, form.travelEndTime); |
| | | return d == null ? "" : String(d); |
| | | }); |
| | | |
| | | const travelTierLabel = computed(() => { |
| | | if (!isTravel.value) return ""; |
| | | 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(() => { |
| | | if (!isTravel.value) return []; |
| | | return buildOverBudgetWarnings( |
| | | form, |
| | | detailTotalAmount.value, |
| | | suggestedHotelLimit.value, |
| | | suggestedTransportSubsidy.value, |
| | | suggestedLivingSubsidy.value |
| | | ); |
| | | }); |
| | | |
| | | const expenseSubjectOptions = computed(() => |
| | | isTravel.value ? EXPENSE_SUBJECT_OPTIONS : COST_SUBJECT_OPTIONS |
| | | ); |
| | | |
| | | const categoryActions = computed(() => |
| | | EXPENSE_CATEGORY_OPTIONS.map(x => ({ name: x.label, value: x.value })) |
| | | ); |
| | | |
| | | const categoryLabel = computed(() => { |
| | | const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === form.expenseCategory); |
| | | return hit?.label || "è¯·éæ©è´¹ç¨ç±»å"; |
| | | }); |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | allUsersCache.value = unwrapUserList(await userListNoPageByTenantId()); |
| | | } catch { |
| | | allUsersCache.value = []; |
| | | } |
| | | } |
| | | |
| | | function userLabel(u) { |
| | | return userSelectLabel(u); |
| | | } |
| | | |
| | | 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 fillApplicantFromUser(u) { |
| | | if (!u) return; |
| | | form.applicantId = u.userId ?? u.id; |
| | | 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 ?? ""; |
| | | } |
| | | |
| | | function onApplicantPicked(uidOrUser) { |
| | | const u = |
| | | typeof uidOrUser === "object" && uidOrUser |
| | | ? uidOrUser |
| | | : userById(uidOrUser); |
| | | fillApplicantFromUser(u); |
| | | } |
| | | |
| | | /** æ°å¢æ¶é»è®¤å¸¦åºå½åç»å½äººï¼åå°é人æ¥éª¤ */ |
| | | function tryApplyCurrentUser() { |
| | | if (modeRef.value === "edit" || form.applicantId) return; |
| | | const id = userStore.id; |
| | | if (!id) return; |
| | | let u = userById(id); |
| | | if (!u) { |
| | | u = { |
| | | userId: id, |
| | | nickName: userStore.nickName, |
| | | userName: userStore.name, |
| | | }; |
| | | } |
| | | fillApplicantFromUser(u); |
| | | } |
| | | |
| | | function recalcTravelStandards() { |
| | | if (!isTravel.value) return; |
| | | form.travelTier = detectTravelTier(form.destination); |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | if (form.hotelStandard == null || form.hotelStandard === "" || 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 === "") { |
| | | form.hotelDays = Math.max(0, days - 1); |
| | | } |
| | | if (form.livingSubsidy == null || form.livingSubsidy === "" || form.livingSubsidy === 0) { |
| | | form.livingSubsidy = suggestedLivingSubsidy.value; |
| | | } |
| | | } |
| | | form.needSpecialApproval = overBudgetWarnings.value.length > 0; |
| | | } |
| | | |
| | | function syncApplyAmountFromDetails() { |
| | | form.applyAmount = detailTotalAmount.value; |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function addExpenseDetail() { |
| | | const row = isTravel.value ? createEmptyExpenseDetail() : createCostDetail(); |
| | | form.expenseDetails.push(row); |
| | | } |
| | | |
| | | function removeExpenseDetail(index) { |
| | | form.expenseDetails.splice(index, 1); |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function applyTemplate(category) { |
| | | applyCategoryTemplate(form, category); |
| | | syncApplyAmountFromDetails(); |
| | | } |
| | | |
| | | function resetFormForModule() { |
| | | const empty = isTravel.value ? createEmptyTravelForm() : createEmptyCostForm(); |
| | | Object.keys(form).forEach(k => delete form[k]); |
| | | Object.assign(form, empty); |
| | | if (!form.approvalFlowNodes?.length) { |
| | | form.approvalFlowNodes = [ |
| | | { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }, |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | async function loadEdit(reimbursementId) { |
| | | loading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | const row = await fetchFinReimbursementFormDetail( |
| | | { reimbursementId }, |
| | | moduleKeyRef.value |
| | | ); |
| | | if (row?.moduleKey && row.moduleKey !== moduleKeyRef.value) { |
| | | moduleKeyRef.value = row.moduleKey; |
| | | } |
| | | Object.assign(form, JSON.parse(JSON.stringify(row)), { |
| | | reimbursementId: row.reimbursementId ?? row.id, |
| | | expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])), |
| | | approvalFlowNodes: JSON.parse( |
| | | JSON.stringify( |
| | | row.approvalFlowNodes?.length |
| | | ? row.approvalFlowNodes |
| | | : [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }] |
| | | ) |
| | | ), |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])), |
| | | }); |
| | | if (!isTravel.value && form.expenseCategory) { |
| | | /* å·²ç± mapCost 转为 value */ |
| | | } |
| | | recalcTravelStandards(); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function initForm() { |
| | | resetFormForModule(); |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | if (modeRef.value !== "edit") { |
| | | form.approvalFlowNodes = [ |
| | | { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }, |
| | | ]; |
| | | tryApplyCurrentUser(); |
| | | } |
| | | } |
| | | |
| | | function validateForm() { |
| | | if (!form.applicantId) { |
| | | uni.showToast({ title: "è¯·éæ©åå·¥", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.reimburseReason || "").trim()) { |
| | | uni.showToast({ title: "è¯·å¡«åæ¥éåå ", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (isTravel.value) { |
| | | if (!form.travelStartTime) { |
| | | uni.showToast({ title: "è¯·éæ©åºå·®å¼å§æ¶é´", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!form.travelEndTime) { |
| | | uni.showToast({ title: "è¯·éæ©åºå·®ç»ææ¶é´", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (computeTravelDays(form.travelStartTime, form.travelEndTime) == null) { |
| | | uni.showToast({ title: "ç»ææ¶é´é¡»æäºå¼å§æ¶é´", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.departurePlace || "").trim()) { |
| | | uni.showToast({ title: "请填ååºå·®å°", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.destination || "").trim()) { |
| | | uni.showToast({ title: "请填åç®çå°", icon: "none" }); |
| | | return false; |
| | | } |
| | | } else if (!form.expenseCategory) { |
| | | uni.showToast({ title: "è¯·éæ©è´¹ç¨ç±»å", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (form.applyAmount === "" || form.applyAmount == null) { |
| | | uni.showToast({ title: "请填åç³è¯·éé¢", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.payee || "").trim()) { |
| | | uni.showToast({ title: "è¯·å¡«åæ¶æ¬¾äºº", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.expenseDetails || []).length) { |
| | | uni.showToast({ title: "请è³å°æ·»å 䏿¡æ¥éæç»", icon: "none" }); |
| | | return false; |
| | | } |
| | | const nodes = form.approvalFlowNodes || []; |
| | | if (!nodes.length || nodes.some(n => n.approverId == null || n.approverId === "")) { |
| | | uni.showToast({ title: "æ¯ä¸ªå®¡æ¹èç¹é¡»éæ©å®¡æ¹äºº", icon: "none" }); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | async function submitForm() { |
| | | if (!validateForm()) return; |
| | | recalcTravelStandards(); |
| | | if (isTravel.value && form.needSpecialApproval) { |
| | | const ok = await new Promise(resolve => { |
| | | uni.showModal({ |
| | | title: "è¶
æ¯æé", |
| | | content: "åå¨è¶
æ¯é¡¹ï¼æäº¤åå°æ 记为éç¹æ¹ï¼æ¯å¦ç»§ç»ï¼", |
| | | success: r => resolve(!!r.confirm), |
| | | }); |
| | | }); |
| | | if (!ok) return; |
| | | } |
| | | const isEdit = modeRef.value === "edit"; |
| | | const dto = isTravel.value |
| | | ? buildTravelReimbursementSaveDto(form, { computeTravelDays }) |
| | | : buildCostReimbursementSaveDto(form); |
| | | const check = validateReimbursementPersistDto(dto, isEdit); |
| | | if (!check.ok) { |
| | | uni.showToast({ title: check.message, icon: "none" }); |
| | | return; |
| | | } |
| | | submitting.value = true; |
| | | try { |
| | | await persistFinReimbursement(dto, isEdit); |
| | | uni.showToast({ title: isEdit ? "ä¿åæå" : "æäº¤æå", icon: "success" }); |
| | | return true; |
| | | } catch { |
| | | uni.showToast({ title: isEdit ? "ä¿å失败" : "æäº¤å¤±è´¥", icon: "none" }); |
| | | return false; |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | form, |
| | | isTravel, |
| | | submitting, |
| | | loading, |
| | | flowUserOptions, |
| | | travelDaysDisplay, |
| | | travelTierLabel, |
| | | suggestedLivingSubsidy, |
| | | suggestedTransportSubsidy, |
| | | suggestedHotelLimit, |
| | | detailTotalAmount, |
| | | overBudgetWarnings, |
| | | expenseSubjectOptions, |
| | | categoryActions, |
| | | categoryLabel, |
| | | showApplicantPicker, |
| | | applicantDisplaySub, |
| | | applicantAvatarColor, |
| | | showCategorySheet, |
| | | showSubjectSheet, |
| | | editingDetailIndex, |
| | | pickCategoryValue, |
| | | pickSubjectValue, |
| | | loadUserPool, |
| | | userLabel, |
| | | onApplicantPicked, |
| | | tryApplyCurrentUser, |
| | | recalcTravelStandards, |
| | | syncApplyAmountFromDetails, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | applyTemplate, |
| | | initForm, |
| | | loadEdit, |
| | | submitForm, |
| | | getReimbursementTypeByModuleKey, |
| | | }; |
| | | } |
| | |
| | | <!-- |
| | | OA / æ¥é管ç / å·®æ
æ¥é |
| | | è·¯ç±ï¼/pages/oa/ReimburseManage/travel-reimburse/index |
| | | OA / æ¥é管ç / å·®æ
æ¥éï¼/finReimbursement/listPageï¼reimbursementType=1ï¼ |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - æ¥é管ç - å·®æ
æ¥é */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "ReimburseManage/travel-reimburse"; |
| | | const { config } = useOaPage(pageKey); |
| | | import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | ä¸å¡å®¡æ¹ç³è¯·å表ï¼è½¬æ£/è°å²/交æ¥/请å/å çï¼ |
| | | --> |
| | | <template> |
| | | <view class="oa-approval-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <view class="oa-toolbar"> |
| | | <view class="oa-filter-chip" |
| | | :class="{ active: hasActiveFilter }" |
| | | @click="showFilter = true"> |
| | | <up-icon name="list" |
| | | size="18" |
| | | :color="hasActiveFilter ? '#2979ff' : '#666'" /> |
| | | <text class="chip-label">çé</text> |
| | | <text v-if="filterSummary" |
| | | class="chip-value">{{ filterSummary }}</text> |
| | | <text v-else |
| | | class="chip-placeholder">å
¨é¨æ¡ä»¶</text> |
| | | </view> |
| | | <view class="oa-icon-btn" |
| | | @click="handleSearch"> |
| | | <up-icon name="search" |
| | | size="20" |
| | | color="#666" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <ApprovalModuleSearchPopup v-model:show="showFilter" |
| | | :module-key="moduleKey" |
| | | v-model="searchForm" |
| | | @search="handleSearch" |
| | | @reset="handleReset" /> |
| | | |
| | | <scroll-view class="oa-list-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false" |
| | | :style="{ height: listScrollHeight + 'px' }" |
| | | @scrolltolower="loadMore"> |
| | | <view v-if="displayList.length" |
| | | class="oa-card-list"> |
| | | <view v-for="item in displayList" |
| | | :key="item.id" |
| | | class="oa-card" |
| | | @click="openDetail(item)"> |
| | | <view class="oa-card-head"> |
| | | <view class="oa-card-title-wrap"> |
| | | <text class="oa-card-title">{{ cardTitle(item) }}</text> |
| | | <text v-if="item.instanceNo" |
| | | class="oa-card-sub">{{ item.instanceNo }}</text> |
| | | </view> |
| | | <text :class="['oa-status', businessStatusClass(item.status)]"> |
| | | {{ businessStatusText(item.status) }} |
| | | </text> |
| | | </view> |
| | | |
| | | <view class="oa-card-body"> |
| | | <view class="oa-info-grid"> |
| | | <view v-for="(row, idx) in visibleDisplayRows(item)" |
| | | :key="'f-' + idx" |
| | | class="oa-info-row"> |
| | | <text class="oa-info-label">{{ row.label }}</text> |
| | | <text class="oa-info-value">{{ row.value || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·äºº</text> |
| | | <text class="oa-info-value">{{ item.applicantName || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·æ¶é´</text> |
| | | <text class="oa-info-value">{{ item.createTime || "-" }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="canEditBusinessInstanceRow(item)" |
| | | class="oa-card-foot" |
| | | @click.stop> |
| | | <text class="oa-foot-btn btn-edit" |
| | | @click="goEdit(item)">ä¿®æ¹</text> |
| | | <text class="oa-foot-btn btn-delete" |
| | | @click="confirmDelete(item)">å é¤</text> |
| | | </view> |
| | | </view> |
| | | <up-loadmore :status="pageStatus" /> |
| | | </view> |
| | | |
| | | <view v-else-if="!tableLoading" |
| | | class="oa-empty"> |
| | | <up-empty mode="list" |
| | | :text="`ææ ${pageTitle}æ°æ®`" /> |
| | | </view> |
| | | <view v-if="tableLoading && !list.length" |
| | | class="oa-loading"> |
| | | <up-loading-icon mode="circle" /> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view class="fab-button" |
| | | @click="handleAdd"> |
| | | <up-icon name="plus" |
| | | size="28" |
| | | color="#ffffff" /> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, onMounted, reactive, ref } from "vue"; |
| | | import { onShow } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue"; |
| | | import { |
| | | deleteApprovalInstance, |
| | | listApprovalInstancePage, |
| | | } from "@/api/oa/approvalInstance.js"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import { fetchApprovalTemplateTypes } from "../_utils/approvalTemplateType.js"; |
| | | import { |
| | | getApprovalModuleConfig, |
| | | getModuleListBusinessType, |
| | | } from "../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | buildModuleListDto, |
| | | createModuleSearchForm, |
| | | filterRowsByModuleSearch, |
| | | filterRowsByModuleBusinessType, |
| | | formatDateRangeLabel, |
| | | getModuleSearchMeta, |
| | | } from "../_utils/approvalModuleListSearch.js"; |
| | | import { |
| | | buildInstanceListParams, |
| | | businessStatusClass, |
| | | businessStatusText, |
| | | canEditBusinessInstanceRow, |
| | | EDIT_STORAGE_KEY, |
| | | mapInstanceListRow, |
| | | stashInstanceRow, |
| | | unwrapInstancePage, |
| | | } from "../_utils/approveListUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | moduleKey: { type: String, required: true }, |
| | | }); |
| | | |
| | | const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey)); |
| | | const pageTitle = computed(() => moduleConfig.value?.label || "ç³è¯·"); |
| | | |
| | | const showFilter = ref(false); |
| | | const searchForm = reactive(createModuleSearchForm(props.moduleKey)); |
| | | const list = ref([]); |
| | | const tableLoading = ref(false); |
| | | const pageStatus = ref("loadmore"); |
| | | const businessType = ref(""); |
| | | const typeOptions = ref([]); |
| | | |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | const listScrollHeight = ref(400); |
| | | |
| | | function calcListScrollHeight() { |
| | | const sys = uni.getSystemInfoSync(); |
| | | const statusBar = sys.statusBarHeight || 0; |
| | | const navBar = 44; |
| | | const toolbar = 56; |
| | | const fabGap = 16; |
| | | listScrollHeight.value = Math.max( |
| | | 200, |
| | | sys.windowHeight - statusBar - navBar - toolbar - fabGap |
| | | ); |
| | | } |
| | | |
| | | const displayList = computed(() => { |
| | | const byType = filterRowsByModuleBusinessType( |
| | | props.moduleKey, |
| | | list.value, |
| | | typeOptions.value |
| | | ); |
| | | return filterRowsByModuleSearch(props.moduleKey, byType, searchForm); |
| | | }); |
| | | |
| | | const hasActiveFilter = computed(() => Boolean(filterSummary.value)); |
| | | |
| | | const filterSummary = computed(() => { |
| | | const parts = []; |
| | | const meta = getModuleSearchMeta(props.moduleKey); |
| | | for (const field of meta.fields || []) { |
| | | const val = searchForm[field.key]; |
| | | if (field.type === "input" && (val || "").trim()) { |
| | | parts.push(`${field.label}:${String(val).trim()}`); |
| | | } else if (field.type === "daterange" && Array.isArray(val) && val[0]) { |
| | | parts.push(`${field.label}:${formatDateRangeLabel(val)}`); |
| | | } else if (field.type === "select" && val) { |
| | | const opt = (field.options || []).find(o => o.value === val); |
| | | parts.push(`${field.label}:${opt?.label || val}`); |
| | | } else if (field.type === "user" && val) { |
| | | parts.push(`${field.label}:å·²é`); |
| | | } |
| | | } |
| | | return parts.join("ï¼"); |
| | | }); |
| | | |
| | | function cardTitle(item) { |
| | | return item.summary || item.title || pageTitle.value; |
| | | } |
| | | |
| | | function visibleDisplayRows(item) { |
| | | const rows = item.displayRows || []; |
| | | return rows.slice(0, 2); |
| | | } |
| | | |
| | | const buildListRequestParams = () => { |
| | | const extraDto = buildModuleListDto(props.moduleKey, searchForm); |
| | | return buildInstanceListParams({ |
| | | page, |
| | | businessType: businessType.value, |
| | | extraDto, |
| | | searchForm, |
| | | }); |
| | | }; |
| | | |
| | | const fetchList = async (reset = false) => { |
| | | if (reset) { |
| | | page.current = 1; |
| | | pageStatus.value = "loadmore"; |
| | | list.value = []; |
| | | } |
| | | if (pageStatus.value === "loading" || pageStatus.value === "nomore") return; |
| | | if (!businessType.value && businessType.value !== 0) return; |
| | | |
| | | pageStatus.value = "loading"; |
| | | tableLoading.value = true; |
| | | |
| | | try { |
| | | const res = await listApprovalInstancePage(buildListRequestParams()); |
| | | const { records, total } = unwrapInstancePage(res); |
| | | const listFields = moduleConfig.value?.listFields || []; |
| | | const mapped = records.map(row => mapInstanceListRow(row, listFields)); |
| | | |
| | | if (page.current === 1) { |
| | | list.value = mapped; |
| | | } else { |
| | | list.value = [...list.value, ...mapped]; |
| | | } |
| | | page.total = total; |
| | | |
| | | if (list.value.length >= total || records.length < page.size) { |
| | | pageStatus.value = "nomore"; |
| | | } else { |
| | | pageStatus.value = "loadmore"; |
| | | page.current += 1; |
| | | } |
| | | } catch { |
| | | if (page.current === 1) list.value = []; |
| | | pageStatus.value = "loadmore"; |
| | | uni.showToast({ title: `${pageTitle.value}å 载失败`, icon: "none" }); |
| | | } finally { |
| | | tableLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | const initBusinessType = async () => { |
| | | const fixed = getModuleListBusinessType(props.moduleKey); |
| | | businessType.value = fixed != null && fixed !== "" ? fixed : ""; |
| | | try { |
| | | typeOptions.value = await fetchApprovalTemplateTypes(); |
| | | } catch { |
| | | typeOptions.value = []; |
| | | } |
| | | }; |
| | | |
| | | const handleSearch = () => fetchList(true); |
| | | const handleReset = () => { |
| | | Object.assign(searchForm, createModuleSearchForm(props.moduleKey)); |
| | | fetchList(true); |
| | | }; |
| | | const loadMore = () => { |
| | | if (pageStatus.value === "loadmore") fetchList(false); |
| | | }; |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const openDetail = item => { |
| | | if (!item?.id) return; |
| | | stashInstanceRow(item); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListDetail}?id=${item.id}&from=business`, |
| | | }); |
| | | }; |
| | | |
| | | const goEdit = item => { |
| | | if (!canEditBusinessInstanceRow(item)) { |
| | | uni.showToast({ title: "è¿è¡ä¸æå·²å®æç审æ¹ä¸å¯ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | if (!item?.id) return; |
| | | uni.setStorageSync(EDIT_STORAGE_KEY, item); |
| | | stashInstanceRow(item); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListApply}?id=${item.id}&moduleKey=${props.moduleKey}`, |
| | | }); |
| | | }; |
| | | |
| | | const confirmDelete = item => { |
| | | if (!item?.id) return; |
| | | const title = item.title || item.templateName || item.instanceNo || "该审æ¹"; |
| | | uni.showModal({ |
| | | title: "å é¤ç¡®è®¤", |
| | | content: `ç¡®å®è¦å é¤ã${title}ãåï¼å é¤åä¸å¯æ¢å¤ã`, |
| | | confirmText: "ç¡®å®å é¤", |
| | | confirmColor: "#f56c6c", |
| | | success: async res => { |
| | | if (!res.confirm) return; |
| | | try { |
| | | await deleteApprovalInstance([item.id]); |
| | | uni.showToast({ title: "å 餿å", icon: "success" }); |
| | | fetchList(true); |
| | | } catch { |
| | | uni.showToast({ title: "å é¤å¤±è´¥", icon: "none" }); |
| | | } |
| | | }, |
| | | }); |
| | | }; |
| | | |
| | | const handleAdd = () => { |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.approveListTemplateSelect}?moduleKey=${props.moduleKey}`, |
| | | }); |
| | | }; |
| | | |
| | | onMounted(() => { |
| | | calcListScrollHeight(); |
| | | }); |
| | | |
| | | onShow(async () => { |
| | | calcListScrollHeight(); |
| | | await initBusinessType(); |
| | | fetchList(true); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "@/styles/sales-common.scss"; |
| | | @import "../_styles/oa-approval-list.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | ä¸å¡å®¡æ¹å表çéå¼¹çª |
| | | --> |
| | | <template> |
| | | <up-popup :show="modelShow" |
| | | mode="bottom" |
| | | round |
| | | @close="closePopup"> |
| | | <view class="oa-filter-popup"> |
| | | <view class="oa-filter-head">ç鿡件</view> |
| | | <scroll-view scroll-y |
| | | class="oa-filter-scroll"> |
| | | <view v-for="field in fields" |
| | | :key="field.key" |
| | | class="oa-filter-field"> |
| | | <text class="oa-field-label">{{ field.label }}</text> |
| | | |
| | | <up-input v-if="field.type === 'input'" |
| | | v-model="localForm[field.key]" |
| | | :placeholder="field.placeholder || `请è¾å
¥${field.label}`" |
| | | clearable |
| | | border="surround" /> |
| | | |
| | | <view v-else-if="field.type === 'daterange'" |
| | | class="oa-picker-row" |
| | | @click="openDateRange(field.key)"> |
| | | <text :class="{ placeholder: !dateLabel(field.key) }"> |
| | | {{ dateLabel(field.key) || `è¯·éæ©${field.label}` }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#999" /> |
| | | </view> |
| | | |
| | | <view v-else-if="field.type === 'user'" |
| | | class="oa-picker-row" |
| | | @click="openUserPicker"> |
| | | <text :class="{ placeholder: !userLabel }"> |
| | | {{ userLabel || field.placeholder || 'è¯·éæ©ç³è¯·äºº' }} |
| | | </text> |
| | | <up-icon name="arrow-down" |
| | | size="14" |
| | | color="#999" /> |
| | | </view> |
| | | |
| | | <view v-else-if="field.type === 'select'" |
| | | class="oa-picker-row" |
| | | @click="openSelect(field)"> |
| | | <text :class="{ placeholder: !selectLabel(field) }"> |
| | | {{ selectLabel(field) || `è¯·éæ©${field.label}` }} |
| | | </text> |
| | | <up-icon name="arrow-down" |
| | | size="14" |
| | | color="#999" /> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view class="oa-filter-actions"> |
| | | <up-button text="éç½®" |
| | | shape="circle" |
| | | @click="handleReset" /> |
| | | <up-button type="primary" |
| | | text="ç¡®å®" |
| | | shape="circle" |
| | | @click="handleConfirm" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <up-calendar :show="calendarShow" |
| | | mode="range" |
| | | :maxDate="maxDate" |
| | | minDate="2020-01-01" |
| | | :monthNum="24" |
| | | @confirm="onDateConfirm" |
| | | @close="calendarShow = false" /> |
| | | |
| | | <up-picker :show="pickerShow" |
| | | :columns="pickerColumns" |
| | | keyName="label" |
| | | @confirm="onPickerConfirm" |
| | | @cancel="pickerShow = false" |
| | | @close="pickerShow = false" /> |
| | | |
| | | <up-popup :show="userPickerShow" |
| | | mode="bottom" |
| | | round |
| | | @close="userPickerShow = false"> |
| | | <view class="oa-user-popup"> |
| | | <view class="oa-user-popup-title">éæ©ç³è¯·äºº</view> |
| | | <up-input v-model="userKeyword" |
| | | placeholder="æç´¢å§åæç¼å·" |
| | | clearable |
| | | border="surround" /> |
| | | <scroll-view scroll-y |
| | | class="oa-user-list"> |
| | | <view v-for="u in filteredUsers" |
| | | :key="u.userId ?? u.id" |
| | | class="oa-user-item" |
| | | @click="pickUser(u)"> |
| | | <text>{{ userSelectLabel(u) }}</text> |
| | | </view> |
| | | <view v-if="!filteredUsers.length" |
| | | class="oa-user-empty">ææ å¹é
ç¨æ·</view> |
| | | </scroll-view> |
| | | </view> |
| | | </up-popup> |
| | | </up-popup> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import dayjs from "dayjs"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user"; |
| | | import { |
| | | formatDateRangeLabel, |
| | | getModuleSearchMeta, |
| | | resetModuleSearchForm, |
| | | userSelectLabel, |
| | | } from "../_utils/approvalModuleListSearch.js"; |
| | | |
| | | const props = defineProps({ |
| | | show: { type: Boolean, default: false }, |
| | | moduleKey: { type: String, required: true }, |
| | | modelValue: { type: Object, default: () => ({}) }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:show", "update:modelValue", "search", "reset"]); |
| | | |
| | | const modelShow = computed({ |
| | | get: () => props.show, |
| | | set: v => emit("update:show", v), |
| | | }); |
| | | |
| | | function closePopup() { |
| | | emit("update:show", false); |
| | | } |
| | | |
| | | const localForm = reactive({}); |
| | | const calendarShow = ref(false); |
| | | const activeDateKey = ref("applyDateRange"); |
| | | const maxDate = dayjs().format("YYYY-MM-DD"); |
| | | const pickerShow = ref(false); |
| | | const pickerColumns = ref([[]]); |
| | | const activeSelectField = ref(null); |
| | | const userPickerShow = ref(false); |
| | | const userKeyword = ref(""); |
| | | const allUsers = ref([]); |
| | | |
| | | const fields = computed(() => getModuleSearchMeta(props.moduleKey).fields || []); |
| | | |
| | | const userLabel = computed(() => { |
| | | const id = localForm.applicantId; |
| | | if (!id) return ""; |
| | | const u = allUsers.value.find(x => String(x.userId ?? x.id) === String(id)); |
| | | return u ? userSelectLabel(u) : String(id); |
| | | }); |
| | | |
| | | const filteredUsers = computed(() => { |
| | | const q = userKeyword.value.trim().toLowerCase(); |
| | | const list = allUsers.value.filter(u => { |
| | | if (u.delFlag === "2" || u.delFlag === 2) return false; |
| | | if (u.status != null && String(u.status) !== "0") return false; |
| | | return true; |
| | | }); |
| | | if (!q) return list.slice(0, 50); |
| | | return list |
| | | .filter(u => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const name = (u.userName || "").toLowerCase(); |
| | | const id = String(u.userId ?? u.id ?? ""); |
| | | return nick.includes(q) || name.includes(q) || id.includes(q); |
| | | }) |
| | | .slice(0, 50); |
| | | }); |
| | | |
| | | function unwrapUsers(res) { |
| | | if (Array.isArray(res)) return res; |
| | | if (Array.isArray(res?.data)) return res.data; |
| | | if (Array.isArray(res?.rows)) return res.rows; |
| | | return []; |
| | | } |
| | | |
| | | async function loadUsers() { |
| | | if (allUsers.value.length) return; |
| | | try { |
| | | const res = await userListNoPageByTenantId(); |
| | | allUsers.value = unwrapUsers(res); |
| | | } catch { |
| | | allUsers.value = []; |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => props.show, |
| | | v => { |
| | | if (!v) return; |
| | | Object.assign(localForm, props.modelValue || {}); |
| | | } |
| | | ); |
| | | |
| | | function dateLabel(key) { |
| | | return formatDateRangeLabel(localForm[key]); |
| | | } |
| | | |
| | | function openDateRange(key) { |
| | | activeDateKey.value = key; |
| | | calendarShow.value = true; |
| | | } |
| | | |
| | | function onDateConfirm(e) { |
| | | if (!e?.length) { |
| | | calendarShow.value = false; |
| | | return; |
| | | } |
| | | localForm[activeDateKey.value] = [e[0], e[e.length - 1]]; |
| | | calendarShow.value = false; |
| | | } |
| | | |
| | | function selectLabel(field) { |
| | | const val = localForm[field.key]; |
| | | if (!val) return ""; |
| | | const opt = (field.options || []).find(o => o.value === val); |
| | | return opt?.label || String(val); |
| | | } |
| | | |
| | | function openSelect(field) { |
| | | activeSelectField.value = field; |
| | | pickerColumns.value = [field.options || []]; |
| | | pickerShow.value = true; |
| | | } |
| | | |
| | | function onPickerConfirm(e) { |
| | | const item = e?.value?.[0]; |
| | | if (activeSelectField.value?.key && item) { |
| | | localForm[activeSelectField.value.key] = item.value; |
| | | } |
| | | pickerShow.value = false; |
| | | } |
| | | |
| | | async function openUserPicker() { |
| | | await loadUsers(); |
| | | userKeyword.value = ""; |
| | | userPickerShow.value = true; |
| | | } |
| | | |
| | | function pickUser(u) { |
| | | localForm.applicantId = u.userId ?? u.id ?? ""; |
| | | userPickerShow.value = false; |
| | | } |
| | | |
| | | function handleReset() { |
| | | resetModuleSearchForm(props.moduleKey, localForm); |
| | | emit("update:modelValue", { ...localForm }); |
| | | emit("reset", { ...localForm }); |
| | | emit("update:show", false); |
| | | } |
| | | |
| | | function handleConfirm() { |
| | | emit("update:modelValue", { ...localForm }); |
| | | emit("search", { ...localForm }); |
| | | emit("update:show", false); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../_styles/oa-approval-list.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥éå表ï¼/finReimbursement/listPageï¼ |
| | | --> |
| | | <template> |
| | | <view class="oa-approval-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <view class="oa-toolbar"> |
| | | <view class="oa-filter-chip" |
| | | :class="{ active: hasActiveFilter }" |
| | | @click="showFilter = true"> |
| | | <up-icon name="list" |
| | | size="18" |
| | | :color="hasActiveFilter ? '#2979ff' : '#666'" /> |
| | | <text class="chip-label">çé</text> |
| | | <text v-if="filterSummary" |
| | | class="chip-value">{{ filterSummary }}</text> |
| | | <text v-else |
| | | class="chip-placeholder">å
¨é¨æ¡ä»¶</text> |
| | | </view> |
| | | <view class="oa-icon-btn" |
| | | @click="handleSearch"> |
| | | <up-icon name="search" |
| | | size="20" |
| | | color="#666" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <ApprovalModuleSearchPopup v-model:show="showFilter" |
| | | :module-key="moduleKey" |
| | | v-model="searchForm" |
| | | @search="handleSearch" |
| | | @reset="handleReset" /> |
| | | |
| | | <scroll-view class="oa-list-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false" |
| | | :style="{ height: listScrollHeight + 'px' }" |
| | | @scrolltolower="loadMore"> |
| | | <view v-if="displayList.length" |
| | | class="oa-card-list"> |
| | | <view v-for="item in displayList" |
| | | :key="item.reimbursementId || item.id" |
| | | class="oa-card"> |
| | | <view class="oa-card-head"> |
| | | <view class="oa-card-title-wrap"> |
| | | <text class="oa-card-title">{{ cardTitle(item) }}</text> |
| | | <text v-if="item.billNo" |
| | | class="oa-card-sub">{{ item.billNo }}</text> |
| | | </view> |
| | | <text :class="['oa-status', billStatusCssClass(item)]"> |
| | | {{ billStatusLabel(item.billStatus ?? item.status) }} |
| | | </text> |
| | | </view> |
| | | |
| | | <view class="oa-card-body"> |
| | | <view class="oa-info-grid"> |
| | | <view v-for="(row, idx) in visibleDisplayRows(item)" |
| | | :key="'f-' + idx" |
| | | class="oa-info-row"> |
| | | <text class="oa-info-label">{{ row.label }}</text> |
| | | <text class="oa-info-value">{{ row.value || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·äºº</text> |
| | | <text class="oa-info-value">{{ item.applicantName || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·æ¶é´</text> |
| | | <text class="oa-info-value">{{ formatListTime(item.createTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="oa-card-foot" |
| | | @click.stop> |
| | | <text class="oa-foot-btn btn-detail" |
| | | @click="openDetail(item)">详æ
</text> |
| | | <text v-if="canEditReimbursementRow(item)" |
| | | class="oa-foot-btn btn-edit" |
| | | @click="goEdit(item)">ä¿®æ¹</text> |
| | | <text v-if="canDeleteReimbursementRow(item)" |
| | | class="oa-foot-btn btn-delete" |
| | | @click="confirmDelete(item)">å é¤</text> |
| | | </view> |
| | | </view> |
| | | <up-loadmore :status="pageStatus" /> |
| | | </view> |
| | | |
| | | <view v-else-if="!tableLoading" |
| | | class="oa-empty"> |
| | | <up-empty mode="list" |
| | | :text="`ææ ${pageTitle}æ°æ®`" /> |
| | | </view> |
| | | <view v-if="tableLoading && !list.length" |
| | | class="oa-loading"> |
| | | <up-loading-icon mode="circle" /> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view class="fab-button" |
| | | @click="handleAdd"> |
| | | <up-icon name="plus" |
| | | size="28" |
| | | color="#ffffff" /> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, onMounted, reactive, ref } from "vue"; |
| | | import { onShow } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue"; |
| | | import { listFinReimbursementPage } from "@/api/oa/finReimbursement.js"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import { getApprovalModuleConfig } from "../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | createModuleSearchForm, |
| | | filterRowsByModuleSearch, |
| | | formatDateRangeLabel, |
| | | getModuleSearchMeta, |
| | | } from "../_utils/approvalModuleListSearch.js"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { |
| | | billStatusCssClass, |
| | | billStatusLabel, |
| | | buildFinReimbursementListParams, |
| | | canDeleteReimbursementRow, |
| | | canEditReimbursementRow, |
| | | deleteFinReimbursement, |
| | | getReimbursementTypeByModuleKey, |
| | | filterRowsByReimbursementType, |
| | | mapFinReimbursementFromApi, |
| | | resolveReimbursementDeleteId, |
| | | unwrapFinReimbursementPage, |
| | | } from "../_utils/finReimbursementMappers.js"; |
| | | |
| | | const props = defineProps({ |
| | | moduleKey: { type: String, required: true }, |
| | | }); |
| | | |
| | | const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey)); |
| | | const pageTitle = computed(() => moduleConfig.value?.label || "æ¥é"); |
| | | const reimbursementType = computed(() => |
| | | getReimbursementTypeByModuleKey(props.moduleKey) |
| | | ); |
| | | |
| | | const showFilter = ref(false); |
| | | const searchForm = reactive(createModuleSearchForm(props.moduleKey)); |
| | | const list = ref([]); |
| | | const tableLoading = ref(false); |
| | | const pageStatus = ref("loadmore"); |
| | | |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | const listScrollHeight = ref(400); |
| | | |
| | | function calcListScrollHeight() { |
| | | const sys = uni.getSystemInfoSync(); |
| | | const statusBar = sys.statusBarHeight || 0; |
| | | const navBar = 44; |
| | | const toolbar = 56; |
| | | const fabGap = 16; |
| | | listScrollHeight.value = Math.max( |
| | | 200, |
| | | sys.windowHeight - statusBar - navBar - toolbar - fabGap |
| | | ); |
| | | } |
| | | |
| | | const displayList = computed(() => |
| | | filterRowsByModuleSearch(props.moduleKey, list.value, searchForm) |
| | | ); |
| | | |
| | | const hasActiveFilter = computed(() => Boolean(filterSummary.value)); |
| | | |
| | | const filterSummary = computed(() => { |
| | | const parts = []; |
| | | const meta = getModuleSearchMeta(props.moduleKey); |
| | | for (const field of meta.fields || []) { |
| | | const val = searchForm[field.key]; |
| | | if (field.type === "input" && (val || "").trim()) { |
| | | parts.push(`${field.label}:${String(val).trim()}`); |
| | | } else if (field.type === "daterange" && Array.isArray(val) && val[0]) { |
| | | parts.push(`${field.label}:${formatDateRangeLabel(val)}`); |
| | | } else if (field.type === "select" && val) { |
| | | const opt = (field.options || []).find(o => o.value === val); |
| | | parts.push(`${field.label}:${opt?.label || val}`); |
| | | } |
| | | } |
| | | return parts.join("ï¼"); |
| | | }); |
| | | |
| | | function cardTitle(item) { |
| | | return item.summary || item.title || item.reason || pageTitle.value; |
| | | } |
| | | |
| | | function visibleDisplayRows(item) { |
| | | return (item.displayRows || []).slice(0, 3); |
| | | } |
| | | |
| | | function formatListTime(t) { |
| | | if (!t) return "-"; |
| | | const formatted = parseTime(t, "{y}-{m}-{d} {h}:{i}"); |
| | | return formatted || String(t).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | const fetchList = async (reset = false) => { |
| | | if (!reimbursementType.value) return; |
| | | |
| | | if (reset) { |
| | | page.current = 1; |
| | | pageStatus.value = "loadmore"; |
| | | list.value = []; |
| | | } |
| | | if (pageStatus.value === "loading" || pageStatus.value === "nomore") return; |
| | | |
| | | pageStatus.value = "loading"; |
| | | tableLoading.value = true; |
| | | |
| | | try { |
| | | const res = await listFinReimbursementPage( |
| | | buildFinReimbursementListParams({ |
| | | page, |
| | | searchForm, |
| | | reimbursementType: reimbursementType.value, |
| | | }) |
| | | ); |
| | | const { records, total } = unwrapFinReimbursementPage(res); |
| | | const mapped = filterRowsByReimbursementType( |
| | | records, |
| | | reimbursementType.value |
| | | ).map(row => |
| | | mapFinReimbursementFromApi(row, { |
| | | reimbursementType: reimbursementType.value, |
| | | moduleKey: props.moduleKey, |
| | | }) |
| | | ); |
| | | |
| | | if (page.current === 1) { |
| | | list.value = mapped; |
| | | } else { |
| | | list.value = [...list.value, ...mapped]; |
| | | } |
| | | page.total = total; |
| | | |
| | | if (list.value.length >= total || records.length < page.size) { |
| | | pageStatus.value = "nomore"; |
| | | } else { |
| | | pageStatus.value = "loadmore"; |
| | | page.current += 1; |
| | | } |
| | | } catch { |
| | | if (page.current === 1) list.value = []; |
| | | pageStatus.value = "loadmore"; |
| | | uni.showToast({ title: `${pageTitle.value}å 载失败`, icon: "none" }); |
| | | } finally { |
| | | tableLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | const handleSearch = () => fetchList(true); |
| | | const handleReset = () => { |
| | | Object.assign(searchForm, createModuleSearchForm(props.moduleKey)); |
| | | fetchList(true); |
| | | }; |
| | | const loadMore = () => { |
| | | if (pageStatus.value === "loadmore") fetchList(false); |
| | | }; |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const openDetail = item => { |
| | | const rid = resolveReimbursementDeleteId(item); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³æ¥ç详æ
ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseDetail}?moduleKey=${props.moduleKey}&reimbursementId=${rid}`, |
| | | }); |
| | | }; |
| | | |
| | | const handleAdd = () => { |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${props.moduleKey}&mode=add`, |
| | | }); |
| | | }; |
| | | |
| | | const goEdit = item => { |
| | | if (!canEditReimbursementRow(item)) { |
| | | uni.showToast({ title: "审æ¹ä¸æå·²å®æçæ¥éä¸å¯ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | const rid = resolveReimbursementDeleteId(item); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${props.moduleKey}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | }; |
| | | |
| | | const confirmDelete = item => { |
| | | if (!canDeleteReimbursementRow(item)) { |
| | | uni.showToast({ title: "è¯¥ç¶æä¸å¯å é¤", icon: "none" }); |
| | | return; |
| | | } |
| | | const id = resolveReimbursementDeleteId(item); |
| | | if (id == null) { |
| | | uni.showToast({ title: "æ æ³å é¤ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | const title = item.billNo || item.summary || item.title || "该æ¥éå"; |
| | | uni.showModal({ |
| | | title: "å é¤ç¡®è®¤", |
| | | content: `ç¡®å®è¦å é¤ã${title}ãåï¼å é¤åä¸å¯æ¢å¤ã`, |
| | | confirmText: "ç¡®å®å é¤", |
| | | confirmColor: "#f56c6c", |
| | | success: async res => { |
| | | if (!res.confirm) return; |
| | | try { |
| | | await deleteFinReimbursement([id]); |
| | | uni.showToast({ title: "å 餿å", icon: "success" }); |
| | | fetchList(true); |
| | | } catch { |
| | | uni.showToast({ title: "å é¤å¤±è´¥", icon: "none" }); |
| | | } |
| | | }, |
| | | }); |
| | | }; |
| | | |
| | | onMounted(() => { |
| | | calcListScrollHeight(); |
| | | }); |
| | | |
| | | onShow(() => { |
| | | calcListScrollHeight(); |
| | | fetchList(true); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "@/styles/sales-common.scss"; |
| | | @import "../_styles/oa-approval-list.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | OA éç¨ï¼å¯æç´¢çç¨æ·åéå¼¹å±ï¼ç¹éå³ç¡®è®¤ï¼ |
| | | --> |
| | | <template> |
| | | <up-popup :show="show" |
| | | mode="bottom" |
| | | round="16" |
| | | :safe-area-inset-bottom="true" |
| | | @close="emit('update:show', false)"> |
| | | <view class="oa-user-sheet"> |
| | | <view class="sheet-handle" /> |
| | | <view class="sheet-head"> |
| | | <text class="sheet-cancel" |
| | | @click="emit('update:show', false)">åæ¶</text> |
| | | <text class="sheet-title">{{ title }}</text> |
| | | <text class="sheet-spacer" /> |
| | | </view> |
| | | |
| | | <view class="sheet-search"> |
| | | <up-search v-model="keyword" |
| | | placeholder="æç´¢å§åæå·¥å·" |
| | | :show-action="false" |
| | | shape="round" |
| | | bg-color="#f5f7fa" /> |
| | | </view> |
| | | |
| | | <view v-if="selfUser && showSelfQuick" |
| | | class="self-quick" |
| | | @click="pickUser(selfUser)"> |
| | | <view class="user-avatar" |
| | | :style="{ backgroundColor: avatarColor(selfUser.nickName || selfUser.userName) }"> |
| | | {{ (selfUser.nickName || selfUser.userName || "æ").charAt(0) }} |
| | | </view> |
| | | <view class="user-meta"> |
| | | <text class="user-name">éæ¬äºº · {{ userSelectLabel(selfUser) }}</text> |
| | | <text class="user-sub">{{ userSubLabel(selfUser) }}</text> |
| | | </view> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | |
| | | <scroll-view scroll-y |
| | | class="user-scroll" |
| | | :show-scrollbar="false"> |
| | | <view v-for="u in filteredList" |
| | | :key="String(u.userId ?? u.id)" |
| | | class="user-item" |
| | | :class="{ selected: isSelected(u) }" |
| | | @click="pickUser(u)"> |
| | | <view class="user-avatar" |
| | | :style="{ backgroundColor: avatarColor(u.nickName || u.userName) }"> |
| | | {{ (u.nickName || u.userName || "?").charAt(0) }} |
| | | </view> |
| | | <view class="user-meta"> |
| | | <text class="user-name">{{ userSelectLabel(u) }}</text> |
| | | <text class="user-sub">{{ userSubLabel(u) }}</text> |
| | | </view> |
| | | <view class="user-check" |
| | | :class="{ checked: isSelected(u) }"> |
| | | <up-icon v-if="isSelected(u)" |
| | | name="checkmark" |
| | | size="14" |
| | | color="#fff" /> |
| | | </view> |
| | | </view> |
| | | <view v-if="!filteredList.length" |
| | | class="user-empty"> |
| | | <up-empty mode="search" |
| | | text="ææ å¹é
ç¨æ·" /> |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | </up-popup> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref, watch } from "vue"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { |
| | | filterActiveUsers, |
| | | userAvatarColor, |
| | | userSelectLabel, |
| | | userSubLabel, |
| | | } from "../_utils/userPickerUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | show: { type: Boolean, default: false }, |
| | | title: { type: String, default: "éæ©åå·¥" }, |
| | | users: { type: Array, default: () => [] }, |
| | | modelValue: { type: [String, Number], default: "" }, |
| | | showSelfQuick: { type: Boolean, default: true }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:show", "update:modelValue", "select"]); |
| | | |
| | | const keyword = ref(""); |
| | | const userStore = useUserStore(); |
| | | |
| | | const filteredList = computed(() => |
| | | filterActiveUsers(props.users, keyword.value, 100) |
| | | ); |
| | | |
| | | const selfUser = computed(() => { |
| | | const id = userStore.id; |
| | | if (!id) return null; |
| | | const hit = props.users.find(u => String(u.userId ?? u.id) === String(id)); |
| | | if (hit) return hit; |
| | | return { |
| | | userId: id, |
| | | nickName: userStore.nickName, |
| | | userName: userStore.name, |
| | | }; |
| | | }); |
| | | |
| | | watch( |
| | | () => props.show, |
| | | v => { |
| | | if (v) keyword.value = ""; |
| | | } |
| | | ); |
| | | |
| | | function avatarColor(name) { |
| | | return userAvatarColor(name); |
| | | } |
| | | |
| | | function isSelected(u) { |
| | | const id = u.userId ?? u.id; |
| | | return id != null && String(id) === String(props.modelValue ?? ""); |
| | | } |
| | | |
| | | function pickUser(u) { |
| | | const id = u.userId ?? u.id; |
| | | emit("update:modelValue", id); |
| | | emit("select", u); |
| | | emit("update:show", false); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .oa-user-sheet { |
| | | background: #fff; |
| | | border-radius: 16px 16px 0 0; |
| | | max-height: 78vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | .sheet-handle { |
| | | width: 36px; |
| | | height: 4px; |
| | | background: #e4e7ed; |
| | | border-radius: 2px; |
| | | margin: 8px auto 4px; |
| | | } |
| | | .sheet-head { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 8px 16px 12px; |
| | | } |
| | | .sheet-cancel { |
| | | font-size: 15px; |
| | | color: #909399; |
| | | min-width: 48px; |
| | | } |
| | | .sheet-title { |
| | | flex: 1; |
| | | text-align: center; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | .sheet-spacer { |
| | | min-width: 48px; |
| | | } |
| | | .sheet-search { |
| | | padding: 0 16px 10px; |
| | | } |
| | | .self-quick { |
| | | display: flex; |
| | | align-items: center; |
| | | margin: 0 16px 8px; |
| | | padding: 12px; |
| | | background: linear-gradient(135deg, #ecf5ff 0%, #f0f9ff 100%); |
| | | border-radius: 12px; |
| | | border: 1px solid #d9ecff; |
| | | } |
| | | .user-scroll { |
| | | flex: 1; |
| | | max-height: 52vh; |
| | | padding: 0 8px 16px; |
| | | box-sizing: border-box; |
| | | } |
| | | .user-item, |
| | | .self-quick { |
| | | &:active { |
| | | opacity: 0.85; |
| | | } |
| | | } |
| | | .user-item { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 12px 10px; |
| | | border-radius: 10px; |
| | | margin-bottom: 4px; |
| | | &.selected { |
| | | background: #f0f7ff; |
| | | } |
| | | } |
| | | .user-avatar { |
| | | width: 40px; |
| | | height: 40px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | .user-meta { |
| | | flex: 1; |
| | | margin-left: 12px; |
| | | min-width: 0; |
| | | } |
| | | .user-name { |
| | | display: block; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .user-sub { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .user-check { |
| | | width: 22px; |
| | | height: 22px; |
| | | border-radius: 50%; |
| | | border: 2px solid #dcdfe6; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | &.checked { |
| | | background: #2979ff; |
| | | border-color: #2979ff; |
| | | } |
| | | } |
| | | .user-empty { |
| | | padding: 24px 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // OA 审æ¹å表ï¼ä¸å¡ç³è¯· + 审æ¹å表ï¼ç»ä¸æ ·å¼ |
| | | |
| | | .oa-approval-page { |
| | | min-height: 100vh; |
| | | background: #f2f4f7; |
| | | display: flex; |
| | | flex-direction: column; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .oa-list-scroll { |
| | | width: 100%; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .oa-toolbar { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 10px 16px; |
| | | background: #fff; |
| | | border-bottom: 1px solid #eef0f3; |
| | | } |
| | | |
| | | .oa-filter-chip { |
| | | flex: 1; |
| | | min-height: 40px; |
| | | padding: 0 14px; |
| | | background: #f5f7fa; |
| | | border-radius: 20px; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | border: 1px solid transparent; |
| | | |
| | | &.active { |
| | | background: #ecf3ff; |
| | | border-color: #b3d4ff; |
| | | } |
| | | |
| | | .chip-label { |
| | | font-size: 14px; |
| | | color: #333; |
| | | font-weight: 500; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .chip-value { |
| | | flex: 1; |
| | | font-size: 13px; |
| | | color: #666; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .chip-placeholder { |
| | | flex: 1; |
| | | font-size: 13px; |
| | | color: #aaa; |
| | | } |
| | | } |
| | | |
| | | .oa-icon-btn { |
| | | width: 40px; |
| | | height: 40px; |
| | | border-radius: 20px; |
| | | background: #f5f7fa; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | |
| | | &:active { |
| | | background: #e8ebf0; |
| | | } |
| | | } |
| | | |
| | | .oa-card-list { |
| | | padding: 12px 16px 4px; |
| | | } |
| | | |
| | | .oa-card { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | margin-bottom: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); |
| | | |
| | | &:active { |
| | | opacity: 0.92; |
| | | } |
| | | } |
| | | |
| | | .oa-card-head { |
| | | padding: 14px 14px 10px; |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .oa-card-title-wrap { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .oa-card-title { |
| | | display: block; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #1a1a1a; |
| | | line-height: 1.4; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .oa-card-sub { |
| | | display: block; |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: #8c8c8c; |
| | | } |
| | | |
| | | .oa-status { |
| | | flex-shrink: 0; |
| | | font-size: 11px; |
| | | line-height: 1; |
| | | padding: 5px 8px; |
| | | border-radius: 4px; |
| | | font-weight: 500; |
| | | |
| | | &.status-pending { |
| | | color: #d46b08; |
| | | background: #fff7e6; |
| | | } |
| | | |
| | | &.status-approved { |
| | | color: #389e0d; |
| | | background: #f6ffed; |
| | | } |
| | | |
| | | &.status-rejected { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | } |
| | | |
| | | &.status-draft { |
| | | color: #595959; |
| | | background: #f5f5f5; |
| | | } |
| | | |
| | | &.status-cancelled { |
| | | color: #8c8c8c; |
| | | background: #fafafa; |
| | | } |
| | | } |
| | | |
| | | .oa-card-body { |
| | | padding: 0 14px 12px; |
| | | } |
| | | |
| | | .oa-info-grid { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .oa-info-row { |
| | | display: flex; |
| | | align-items: baseline; |
| | | font-size: 13px; |
| | | line-height: 1.45; |
| | | } |
| | | |
| | | .oa-info-label { |
| | | width: 72px; |
| | | flex-shrink: 0; |
| | | color: #8c8c8c; |
| | | } |
| | | |
| | | .oa-info-value { |
| | | flex: 1; |
| | | color: #333; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .oa-card-foot { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | gap: 10px; |
| | | padding: 10px 14px; |
| | | background: #fafbfc; |
| | | border-top: 1px solid #f0f2f5; |
| | | } |
| | | |
| | | .oa-foot-btn { |
| | | min-width: 64px; |
| | | height: 32px; |
| | | line-height: 32px; |
| | | padding: 0 14px; |
| | | font-size: 13px; |
| | | border-radius: 16px; |
| | | text-align: center; |
| | | |
| | | &.btn-detail { |
| | | color: #fff; |
| | | background: #2979ff; |
| | | } |
| | | |
| | | &.btn-edit { |
| | | color: #2979ff; |
| | | background: #ecf3ff; |
| | | } |
| | | |
| | | &.btn-delete { |
| | | color: #ff4d4f; |
| | | background: #fff1f0; |
| | | } |
| | | |
| | | &.btn-approve { |
| | | color: #fff; |
| | | background: #2979ff; |
| | | } |
| | | } |
| | | |
| | | .oa-empty, |
| | | .oa-loading { |
| | | padding: 48px 20px; |
| | | } |
| | | |
| | | .oa-loading { |
| | | display: flex; |
| | | justify-content: center; |
| | | } |
| | | |
| | | // çéå¼¹çª |
| | | .oa-filter-popup { |
| | | padding: 16px 16px calc(16px + env(safe-area-inset-bottom)); |
| | | max-height: 72vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | } |
| | | |
| | | .oa-filter-head { |
| | | text-align: center; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #1a1a1a; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .oa-filter-scroll { |
| | | max-height: 50vh; |
| | | } |
| | | |
| | | .oa-filter-field { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .oa-field-label { |
| | | display: block; |
| | | font-size: 13px; |
| | | color: #666; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .oa-picker-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | min-height: 44px; |
| | | padding: 0 14px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | font-size: 14px; |
| | | color: #333; |
| | | |
| | | .placeholder { |
| | | color: #bbb; |
| | | } |
| | | } |
| | | |
| | | .oa-filter-actions { |
| | | display: flex; |
| | | gap: 12px; |
| | | margin-top: 16px; |
| | | padding-top: 12px; |
| | | } |
| | | |
| | | .oa-filter-actions :deep(.u-button) { |
| | | flex: 1; |
| | | border-radius: 22px !important; |
| | | } |
| | | |
| | | .oa-user-popup { |
| | | padding: 16px; |
| | | max-height: 60vh; |
| | | background: #fff; |
| | | } |
| | | |
| | | .oa-user-popup-title { |
| | | text-align: center; |
| | | font-weight: 600; |
| | | font-size: 16px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .oa-user-list { |
| | | max-height: 40vh; |
| | | margin-top: 10px; |
| | | } |
| | | |
| | | .oa-user-item { |
| | | padding: 14px 4px; |
| | | font-size: 14px; |
| | | color: #333; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | } |
| | | |
| | | .oa-user-empty { |
| | | text-align: center; |
| | | color: #999; |
| | | padding: 24px; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | // 详æ
/ 审æ¹å¤çç页åºé¨æä½æ |
| | | .oa-page-footer { |
| | | position: fixed; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 100; |
| | | display: flex; |
| | | flex-direction: row; |
| | | flex-wrap: nowrap; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); |
| | | background: #fff; |
| | | border-top: 1px solid #eef0f3; |
| | | box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.08); |
| | | } |
| | | |
| | | .oa-footer-btn { |
| | | flex: 1; |
| | | min-width: 0; |
| | | height: 44px; |
| | | line-height: 44px; |
| | | text-align: center; |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | border-radius: 22px; |
| | | border: none; |
| | | |
| | | &:active { |
| | | opacity: 0.85; |
| | | } |
| | | |
| | | &.btn-default { |
| | | color: #666; |
| | | background: #f2f4f7; |
| | | } |
| | | |
| | | &.btn-primary { |
| | | color: #fff; |
| | | background: linear-gradient(140deg, #00baff 0%, #006cfb 100%); |
| | | box-shadow: 0 4px 10px rgba(0, 108, 251, 0.25); |
| | | } |
| | | |
| | | &.btn-warn { |
| | | color: #fff; |
| | | background: linear-gradient(140deg, #ffb347 0%, #ff9800 100%); |
| | | box-shadow: 0 4px 10px rgba(255, 152, 0, 0.25); |
| | | } |
| | | |
| | | &.btn-success { |
| | | color: #fff; |
| | | background: linear-gradient(140deg, #52c41a 0%, #389e0d 100%); |
| | | box-shadow: 0 4px 10px rgba(56, 158, 13, 0.25); |
| | | } |
| | | |
| | | &.btn-danger { |
| | | color: #fff; |
| | | background: linear-gradient(140deg, #ff7875 0%, #f5222d 100%); |
| | | box-shadow: 0 4px 10px rgba(245, 34, 45, 0.2); |
| | | } |
| | | |
| | | &.is-disabled { |
| | | opacity: 0.5; |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | |
| | | .oa-detail-page { |
| | | min-height: 100vh; |
| | | background: #f2f4f7; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .oa-detail-scroll { |
| | | flex: 1; |
| | | height: 0; |
| | | padding: 10px 12px; |
| | | padding-bottom: calc(76px + env(safe-area-inset-bottom)); |
| | | box-sizing: border-box; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | |
| | | /** å¡«æ¥å段类åï¼ä¸æ */ |
| | | export const SELECT_FIELD_TYPES = new Set(["select", "dropdown", "picker"]); |
| | | |
| | | /** æ¥ææ¶é´ç±» type */ |
| | | const DATE_KIND_BY_TYPE = { |
| | | date: "date", |
| | | time: "time", |
| | | datetime: "datetime", |
| | | datetimerange: "datetime", |
| | | }; |
| | | |
| | | const DEFAULT_FORMAT = { |
| | | date: "YYYY-MM-DD", |
| | | time: "HH:mm", |
| | | datetime: "YYYY-MM-DD HH:mm:ss", |
| | | }; |
| | | |
| | | /** è§£æ formConfig JSONï¼å«åµå¥ formPayloadï¼ä¸ Web parseInstanceFormConfig ä¸è´ï¼ */ |
| | | export function parseApprovalFormConfig(raw) { |
| | | if (!raw) return { prompt: "", fields: [], formPayload: {} }; |
| | | try { |
| | | const obj = typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | const payload = obj?.formPayload; |
| | | return { |
| | | prompt: obj?.prompt || obj?.summaryPlaceholder || "", |
| | | summaryPlaceholder: obj?.summaryPlaceholder || "", |
| | | approvalType: obj?.approvalType || "", |
| | | fields: Array.isArray(obj?.fields) |
| | | ? obj.fields |
| | | : Array.isArray(obj?.formFields) |
| | | ? obj.formFields |
| | | : [], |
| | | formPayload: payload && typeof payload === "object" ? payload : {}, |
| | | }; |
| | | } catch { |
| | | return { prompt: "", fields: [], formPayload: {} }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * ä¿®æ¹å®¡æ¹ï¼æ¨¡æ¿å段å®ä¹ + å®ä¾å·²å¡«å¼åå¹¶ |
| | | */ |
| | | export function mergeFormConfigForEdit(templateRaw, instanceRaw) { |
| | | const template = parseApprovalFormConfig(templateRaw); |
| | | const instance = parseApprovalFormConfig(instanceRaw); |
| | | const valueMap = {}; |
| | | instance.fields.forEach(field => { |
| | | if (!field?.key) return; |
| | | const val = field.value ?? field.defaultValue; |
| | | if (val !== undefined && val !== null && val !== "") { |
| | | valueMap[field.key] = val; |
| | | } |
| | | }); |
| | | Object.assign(valueMap, instance.formPayload || {}); |
| | | const baseFields = template.fields.length ? template.fields : instance.fields; |
| | | return { |
| | | prompt: instance.prompt || template.prompt, |
| | | fields: baseFields.map(field => ({ |
| | | ...field, |
| | | value: valueMap[field.key] ?? field.value ?? field.defaultValue ?? "", |
| | | })), |
| | | }; |
| | | } |
| | | |
| | | /** æ¯å¦ä¸ºä¸æç±»å段 */ |
| | | export function isSelectField(field) { |
| | | const type = String(field?.type ?? "").toLowerCase(); |
| | | return SELECT_FIELD_TYPES.has(type); |
| | | } |
| | | |
| | | /** æ¯å¦ä¸ºå¤è¡ææ¬ */ |
| | | export function isTextareaField(field) { |
| | | return String(field?.type ?? "").toLowerCase() === "textarea"; |
| | | } |
| | | |
| | | /** 读ååæ®µé
ç½®çæ¥ææ ¼å¼ï¼å
¼å®¹ format / dateFormat / timeFormatï¼ */ |
| | | export function getFieldFormatStr(field) { |
| | | return ( |
| | | field?.format ?? field?.dateFormat ?? field?.timeFormat ?? "" |
| | | ).trim(); |
| | | } |
| | | |
| | | /** æ¥ææ¶é´å段ç§ç±»ï¼date | time | datetime | null */ |
| | | export function getDateFieldKind(field) { |
| | | const type = String(field?.type ?? "").toLowerCase(); |
| | | if (DATE_KIND_BY_TYPE[type]) return DATE_KIND_BY_TYPE[type]; |
| | | |
| | | const fmt = getFieldFormatStr(field); |
| | | if (!fmt) return null; |
| | | const hasDate = /Y{2,4}|D{1,2}/i.test(fmt); |
| | | const hasTime = /H{1,2}|h{1,2}|m{1,2}|s{1,2}/i.test(fmt); |
| | | if (hasDate && hasTime) return "datetime"; |
| | | if (hasTime && !hasDate) return "time"; |
| | | if (hasDate) return "date"; |
| | | return null; |
| | | } |
| | | |
| | | /** æ¯å¦ä¸ºæ¥æ/æ¶é´ç±»å段ï¼ä¸å«æ¥ææ¶é´èå´ï¼ */ |
| | | export function isDateLikeField(field) { |
| | | if (isDatetimerangeField(field)) return false; |
| | | return !!getDateFieldKind(field); |
| | | } |
| | | |
| | | /** @deprecated ä½¿ç¨ isDateLikeField */ |
| | | export function isDateField(field) { |
| | | return isDateLikeField(field); |
| | | } |
| | | |
| | | /** uView datetime-picker ç mode */ |
| | | export function getDatePickerMode(field) { |
| | | const kind = getDateFieldKind(field); |
| | | if (kind === "time") return "time"; |
| | | if (kind === "datetime") return "datetime"; |
| | | return "date"; |
| | | } |
| | | |
| | | /** moment 飿 ¼æ ¼å¼ â parseTime æ¨¡æ¿ */ |
| | | export function momentFormatToParsePattern(fmt) { |
| | | if (!fmt) return null; |
| | | return fmt |
| | | .replace(/YYYY/g, "{y}") |
| | | .replace(/YY/g, "{y}") |
| | | .replace(/DD/g, "{d}") |
| | | .replace(/dd/g, "{d}") |
| | | .replace(/MM/g, "{m}") |
| | | .replace(/HH/g, "{h}") |
| | | .replace(/hh/g, "{h}") |
| | | .replace(/mm/g, "{i}") |
| | | .replace(/ss/g, "{s}"); |
| | | } |
| | | |
| | | /** å°æ¶é´æ³/Date æ ¼å¼åä¸ºåæ®µé
ç½®æ ¼å¼ */ |
| | | export function formatFieldDateValue(field, dateSource) { |
| | | const kind = getDateFieldKind(field); |
| | | if (!kind) return ""; |
| | | const fmt = getFieldFormatStr(field) || DEFAULT_FORMAT[kind]; |
| | | const pattern = momentFormatToParsePattern(fmt); |
| | | let date; |
| | | if (typeof dateSource === "number") date = new Date(dateSource); |
| | | else if (dateSource instanceof Date) date = dateSource; |
| | | else return String(dateSource ?? ""); |
| | | return parseTime(date, pattern) || ""; |
| | | } |
| | | |
| | | /** å±ç¤ºç¨ï¼å°å·²å弿é
ç½®æ ¼å¼åæ¾ */ |
| | | export function formatFieldDisplayValue(field, storedValue) { |
| | | if (storedValue === undefined || storedValue === null || storedValue === "") { |
| | | return ""; |
| | | } |
| | | if (!getDateFieldKind(field)) return String(storedValue); |
| | | const ts = parseFieldDateToTs(storedValue); |
| | | if (ts) return formatFieldDateValue(field, ts); |
| | | return String(storedValue); |
| | | } |
| | | |
| | | /** å°å·²åæ¥æå符串转为æ¶é´æ³ï¼ä¾éæ©å¨åå§å¼ï¼ */ |
| | | export function parseFieldDateToTs(value) { |
| | | if (value === undefined || value === null || value === "") return null; |
| | | if (typeof value === "number") return value; |
| | | const str = String(value).trim(); |
| | | const normalized = str.replace(/-/g, "/").replace("T", " "); |
| | | const t = new Date(normalized).getTime(); |
| | | return Number.isNaN(t) ? null : t; |
| | | } |
| | | |
| | | /** æ¯å¦ä¸ºæ°å */ |
| | | export function isNumberField(field) { |
| | | return String(field?.type ?? "").toLowerCase() === "number"; |
| | | } |
| | | |
| | | /** |
| | | * å°å段é
ç½®ä¸çé项è§è为 { label, value }[] |
| | | * æ¯æï¼options / optionListï¼é¡¹ä¸ºå符串æ { label|name|text, value|key|code } |
| | | */ |
| | | export function normalizeFieldOptions(field) { |
| | | const raw = |
| | | field?.options ?? field?.optionList ?? field?.dictOptions ?? field?.items; |
| | | if (!Array.isArray(raw) || !raw.length) return []; |
| | | |
| | | return raw |
| | | .map((item, index) => { |
| | | if (item == null) return null; |
| | | if (typeof item === "string" || typeof item === "number") { |
| | | const text = String(item); |
| | | return { label: text, value: text }; |
| | | } |
| | | if (typeof item !== "object") return null; |
| | | |
| | | const label = |
| | | item.label ?? |
| | | item.name ?? |
| | | item.text ?? |
| | | item.dictLabel ?? |
| | | item.title; |
| | | const rawValue = |
| | | item.value ?? item.key ?? item.code ?? item.dictValue ?? item.id; |
| | | |
| | | if (label == null && rawValue == null) return null; |
| | | |
| | | const value = |
| | | rawValue !== undefined && rawValue !== null ? rawValue : label ?? index; |
| | | return { |
| | | label: String(label ?? value), |
| | | value, |
| | | }; |
| | | }) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | /** æåå¨å¼å¹é
é项å±ç¤ºææ¡ */ |
| | | export function getFieldOptionLabel(field, storedValue) { |
| | | if (storedValue === undefined || storedValue === null || storedValue === "") { |
| | | return ""; |
| | | } |
| | | const options = normalizeFieldOptions(field); |
| | | const strVal = String(storedValue); |
| | | const matched = options.find( |
| | | opt => |
| | | String(opt.value) === strVal || |
| | | String(opt.label) === strVal |
| | | ); |
| | | return matched?.label ?? ""; |
| | | } |
| | | |
| | | /** åå§åå¡«æ¥å¼ï¼ä¼å
已填 valueï¼å
¶æ¬¡ defaultValue */ |
| | | export function getFieldInitialValue(field) { |
| | | if (field?.value !== undefined && field?.value !== null && field?.value !== "") { |
| | | return field.value; |
| | | } |
| | | if ( |
| | | field?.defaultValue !== undefined && |
| | | field?.defaultValue !== null && |
| | | field?.defaultValue !== "" |
| | | ) { |
| | | return field.defaultValue; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** 模æ¿ç¼è¾ï¼æ§ä»¶ç±»åé项ï¼ä¸ Web 端ä¸è´ï¼ */ |
| | | export const FIELD_EDITOR_TYPE_OPTIONS = [ |
| | | { name: "åè¡ææ¬", value: "text" }, |
| | | { name: "å¤è¡ææ¬", value: "textarea" }, |
| | | { name: "æ°å", value: "number" }, |
| | | { name: "æ¥æ", value: "date" }, |
| | | { name: "æ¥ææ¶é´èå´", value: "datetimerange" }, |
| | | { name: "䏿鿩", value: "select" }, |
| | | ]; |
| | | |
| | | /** 䏿éé¡¹æ¥æº */ |
| | | export const FIELD_OPTION_SOURCE_OPTIONS = [ |
| | | { name: "æå¨é
ç½®", value: "manual" }, |
| | | { name: "人åå表", value: "user" }, |
| | | { name: "é¨é¨å表", value: "dept" }, |
| | | ]; |
| | | |
| | | const OPTION_SOURCE_ALIASES = { |
| | | manual: "manual", |
| | | user: "user", |
| | | personnel: "user", |
| | | userlist: "user", |
| | | dept: "dept", |
| | | department: "dept", |
| | | deptlist: "dept", |
| | | }; |
| | | |
| | | export function getFieldEditorTypeLabel(type) { |
| | | const found = FIELD_EDITOR_TYPE_OPTIONS.find( |
| | | item => String(item.value) === String(type) |
| | | ); |
| | | return found?.name || type || "-"; |
| | | } |
| | | |
| | | export function getFieldOptionSourceLabel(source) { |
| | | const key = getFieldOptionSource(source); |
| | | const found = FIELD_OPTION_SOURCE_OPTIONS.find(item => item.value === key); |
| | | return found?.name || "æå¨é
ç½®"; |
| | | } |
| | | |
| | | /** è§£æéé¡¹æ¥æºï¼manual | user | dept */ |
| | | export function getFieldOptionSource(fieldOrSource) { |
| | | const raw = |
| | | typeof fieldOrSource === "object" |
| | | ? fieldOrSource?.optionSource |
| | | : fieldOrSource; |
| | | const key = String(raw ?? "manual") |
| | | .trim() |
| | | .toLowerCase(); |
| | | return OPTION_SOURCE_ALIASES[key] || "manual"; |
| | | } |
| | | |
| | | export function isDatetimerangeField(field) { |
| | | return String(field?.type ?? "").toLowerCase() === "datetimerange"; |
| | | } |
| | | |
| | | /** è§£ææ¥ææ¶é´èå´é»è®¤å¼ï¼start,end */ |
| | | export function parseDatetimerangeValue(stored) { |
| | | if (stored === undefined || stored === null || stored === "") { |
| | | return { start: "", end: "" }; |
| | | } |
| | | const parts = String(stored) |
| | | .split(",") |
| | | .map(s => s.trim()); |
| | | return { start: parts[0] || "", end: parts[1] || "" }; |
| | | } |
| | | |
| | | export function joinDatetimerangeValue(start, end) { |
| | | const s = String(start ?? "").trim(); |
| | | const e = String(end ?? "").trim(); |
| | | if (!s && !e) return ""; |
| | | return `${s},${e}`; |
| | | } |
| | | |
| | | export function formatDatetimerangeDisplay(stored) { |
| | | const { start, end } = parseDatetimerangeValue(stored); |
| | | if (!start && !end) return ""; |
| | | if (start && end) return `${start} è³ ${end}`; |
| | | return start || end; |
| | | } |
| | | |
| | | /** |
| | | * è§£æä¸æé项ï¼å«äººå/é¨é¨å¨ææ¥æºï¼ |
| | | * @param {object} field |
| | | * @param {{ users?: array, depts?: array }} context |
| | | */ |
| | | export function resolveFieldOptions(field, context = {}) { |
| | | const source = getFieldOptionSource(field); |
| | | if (source === "user") { |
| | | return (context.users || []).map(user => ({ |
| | | label: user.nickName || user.userName || String(user.userId ?? ""), |
| | | value: user.userId, |
| | | })); |
| | | } |
| | | if (source === "dept") { |
| | | return (context.depts || []).map(dept => ({ |
| | | label: dept.deptName || dept.name || String(dept.deptId ?? dept.id ?? ""), |
| | | value: dept.deptId ?? dept.id, |
| | | })); |
| | | } |
| | | return normalizeFieldOptions(field); |
| | | } |
| | | |
| | | export function createEmptyFieldOption() { |
| | | return { label: "", value: "" }; |
| | | } |
| | | |
| | | /** å°ç¼è¾è稿è§èä¸ºå¯æäº¤çåæ®µå¯¹è±¡ */ |
| | | export function buildFieldConfigPayload(draft, existingKey) { |
| | | const payload = { |
| | | key: (draft.key || existingKey || "").trim(), |
| | | label: (draft.label || "").trim(), |
| | | type: draft.type || "text", |
| | | required: !!draft.required, |
| | | defaultValue: String(draft.defaultValue ?? "").trim(), |
| | | }; |
| | | if (isSelectField(payload)) { |
| | | payload.optionSource = getFieldOptionSource(draft.optionSource); |
| | | if (payload.optionSource === "manual") { |
| | | payload.options = (draft.options || []) |
| | | .map(opt => ({ |
| | | label: String(opt?.label ?? "").trim(), |
| | | value: String(opt?.value ?? "").trim(), |
| | | })) |
| | | .filter(opt => opt.label && opt.value); |
| | | } else { |
| | | delete payload.options; |
| | | } |
| | | } |
| | | return payload; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | import { |
| | | APPROVAL_MODULE_KEYS, |
| | | APPROVAL_MODULE_REGISTRY, |
| | | getModuleMatchingBusinessTypes, |
| | | } from "./approvalModuleRegistry.js"; |
| | | import { matchBusinessTypeValue } from "./approvalTemplateType.js"; |
| | | import { parseDatetimerangeValue } from "./approvalFormField.js"; |
| | | |
| | | /** 人å䏿忮µè¯å«ï¼ä¸ Web SELECT_OPTION_SOURCE.USER çä»·ï¼ */ |
| | | export function isUserSelectField(field) { |
| | | const src = String(field?.optionSource ?? "").toLowerCase(); |
| | | return ( |
| | | src === "user" || |
| | | src === "personnel" || |
| | | src === "userlist" || |
| | | (field?.type === "select" && String(field?.label || "").includes("ç³è¯·äºº")) |
| | | ); |
| | | } |
| | | |
| | | export function findApplicantTemplateField(fields = []) { |
| | | return ( |
| | | fields.find(f => String(f?.label || "").includes("ç³è¯·äºº")) || |
| | | fields.find(f => isUserSelectField(f)) || |
| | | null |
| | | ); |
| | | } |
| | | |
| | | /* ---------- 请å ---------- */ |
| | | |
| | | export function isLeaveBalanceField(field) { |
| | | const label = String(field?.label || ""); |
| | | return label.includes("åæä½é¢") || field?.key === "leaveBalanceDays"; |
| | | } |
| | | |
| | | export function isLeaveDurationField(field) { |
| | | const label = String(field?.label || ""); |
| | | return label.includes("è¯·åæ¶é¿") || field?.key === "leaveDurationDays"; |
| | | } |
| | | |
| | | export function displayLeaveTemplateFields(fields = []) { |
| | | return (fields || []).filter( |
| | | f => !isLeaveBalanceField(f) && !isLeaveDurationField(f) |
| | | ); |
| | | } |
| | | |
| | | export function findLeaveTimeTemplateField(fields = []) { |
| | | return ( |
| | | fields.find( |
| | | f => f?.type === "datetimerange" && String(f?.label || "").includes("è¯·åæ¶é´") |
| | | ) || |
| | | fields.find(f => f?.type === "datetimerange" && f?.key === "dateRange") || |
| | | fields.find(f => f?.type === "datetimerange") || |
| | | null |
| | | ); |
| | | } |
| | | |
| | | export function resolveTimeRangeFromPayload(payload, timeField) { |
| | | if (!timeField?.key) return { start: "", end: "" }; |
| | | const val = payload?.[timeField.key]; |
| | | if (Array.isArray(val) && val.length >= 2) { |
| | | return { start: val[0] || "", end: val[1] || "" }; |
| | | } |
| | | return parseDatetimerangeValue(val); |
| | | } |
| | | |
| | | export 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; |
| | | } |
| | | |
| | | export function computeLeaveDurationDisplay(fields, formPayload) { |
| | | const timeField = findLeaveTimeTemplateField(fields); |
| | | const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField); |
| | | const d = computeLeaveDays(start, end); |
| | | return d == null ? "" : String(d); |
| | | } |
| | | |
| | | export function validateLeaveBeforeSubmit(fields, formPayload) { |
| | | const timeField = findLeaveTimeTemplateField(fields); |
| | | const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField); |
| | | if (computeLeaveDays(start, end) == null) { |
| | | return "è¯·æ£æ¥æ¨¡æ¿ä¸çè¯·åæ¶é´ï¼ç»ææ¶é´é¡»æäºå¼å§æ¶é´"; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /* ---------- å ç ---------- */ |
| | | |
| | | export function isOvertimeDurationField(field) { |
| | | const label = String(field?.label || ""); |
| | | return label.includes("å çæ¶é¿") || field?.key === "overtimeHours"; |
| | | } |
| | | |
| | | export function displayOvertimeTemplateFields(fields = []) { |
| | | return (fields || []).filter(f => !isOvertimeDurationField(f)); |
| | | } |
| | | |
| | | export function findOvertimeTimeTemplateField(fields = []) { |
| | | return ( |
| | | fields.find( |
| | | f => f?.type === "datetimerange" && String(f?.label || "").includes("å çæ¶é´") |
| | | ) || |
| | | fields.find(f => f?.type === "datetimerange") || |
| | | null |
| | | ); |
| | | } |
| | | |
| | | export 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; |
| | | return Math.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100; |
| | | } |
| | | |
| | | export function computeOvertimeHoursDisplay(fields, formPayload) { |
| | | const field = findOvertimeTimeTemplateField(fields); |
| | | const { start, end } = resolveTimeRangeFromPayload(formPayload, field); |
| | | const h = computeOvertimeHours(start, end); |
| | | return h == null ? "" : String(h); |
| | | } |
| | | |
| | | export function validateOvertimeBeforeSubmit(fields, formPayload) { |
| | | const field = findOvertimeTimeTemplateField(fields); |
| | | const { start, end } = resolveTimeRangeFromPayload(formPayload, field); |
| | | if (computeOvertimeHours(start, end) == null) { |
| | | return "è¯·æ£æ¥æ¨¡æ¿ä¸çå çæ¶é´ï¼ç»ææ¶é´é¡»æäºå¼å§æ¶é´"; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /* ---------- è°å² ---------- */ |
| | | |
| | | export function isOriginalPostField(field) { |
| | | const label = String(field?.label || ""); |
| | | return ( |
| | | label.includes("åå²ä½") || |
| | | field?.key === "originalPost" || |
| | | field?.key === "originalPostName" || |
| | | field?.key === "originalPostId" |
| | | ); |
| | | } |
| | | |
| | | export function displayTransferTemplateFields(fields = []) { |
| | | return (fields || []).filter(f => !isOriginalPostField(f)); |
| | | } |
| | | |
| | | export function unwrapUserArray(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 []; |
| | | } |
| | | |
| | | export function isActiveUser(u) { |
| | | if (u?.delFlag === "2" || u?.delFlag === 2) return false; |
| | | if (u?.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | export 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; |
| | | } |
| | | |
| | | export function buildPostIdToNameMap(postRows = []) { |
| | | const m = {}; |
| | | for (const p of postRows) { |
| | | const id = p.postId ?? p.value ?? p.id; |
| | | if (id != null && id !== "") { |
| | | m[String(id)] = p.postName ?? p.label ?? p.name ?? ""; |
| | | } |
| | | } |
| | | return m; |
| | | } |
| | | |
| | | export function resolveOriginalPostName(user, postIdToName = {}) { |
| | | if (!user) return ""; |
| | | const nameStr = (user.postName ?? user.postname ?? "").toString().trim(); |
| | | if (nameStr) return nameStr; |
| | | if (Array.isArray(user.posts) && user.posts.length) { |
| | | return (user.posts[0].postName ?? "").toString() || "æªå½åå²ä½"; |
| | | } |
| | | const pid = firstPostId(user); |
| | | if (pid != null && pid !== "") { |
| | | const n = postIdToName[String(pid)] || ""; |
| | | return n || "å½åå²ä½ï¼æªå¨å²ä½åå
¸ä¸ï¼"; |
| | | } |
| | | return "æªåé
å²ä½"; |
| | | } |
| | | |
| | | export function userById(users, id) { |
| | | if (id == null || id === "") return undefined; |
| | | return (users || []).find(u => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | /** æ moduleKey è¿æ»¤æ¨¡æ¿å¡«æ¥é¡¹ */ |
| | | export function displayTemplateFieldsByModule(moduleKey, fields = []) { |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) { |
| | | return displayLeaveTemplateFields(fields); |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) { |
| | | return displayOvertimeTemplateFields(fields); |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) { |
| | | return displayTransferTemplateFields(fields); |
| | | } |
| | | return fields || []; |
| | | } |
| | | |
| | | /** ä¿ååå°ä¸å¡æ©å±å段åå
¥ formValues */ |
| | | export function syncModuleExtrasToFormValues(moduleKey, formValues, extras, fields) { |
| | | if (!moduleKey || !formValues) return; |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) { |
| | | if (extras.leaveBalanceDays != null && extras.leaveBalanceDays !== "") { |
| | | formValues.leaveBalanceDays = extras.leaveBalanceDays; |
| | | } |
| | | const days = computeLeaveDurationDisplay(fields, formValues); |
| | | if (days) formValues.leaveDurationDays = days; |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) { |
| | | const hours = computeOvertimeHoursDisplay(fields, formValues); |
| | | if (hours) formValues.overtimeHours = hours; |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER && extras.originalPostName) { |
| | | formValues.originalPostName = extras.originalPostName; |
| | | formValues.originalPost = extras.originalPostName; |
| | | } |
| | | } |
| | | |
| | | /** ä¸å¡æ©å±æ ¡éª */ |
| | | export function validateModuleExtras(moduleKey, fields, formPayload, extras) { |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) { |
| | | if ( |
| | | extras.leaveBalanceDays == null || |
| | | extras.leaveBalanceDays === "" || |
| | | Number.isNaN(Number(extras.leaveBalanceDays)) |
| | | ) { |
| | | return "请填ååæä½é¢"; |
| | | } |
| | | const msg = validateLeaveBeforeSubmit(fields, formPayload); |
| | | if (msg) return msg; |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) { |
| | | const msg = validateOvertimeBeforeSubmit(fields, formPayload); |
| | | if (msg) return msg; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** ä»å®ä¾ businessType æ¨æ moduleKeyï¼ç¼è¾å
¥å£æªå¸¦ moduleKey æ¶ï¼ */ |
| | | export function inferModuleKeyFromRow(row, typeOptions = []) { |
| | | const bt = row?.businessType; |
| | | if (bt == null || bt === "") return ""; |
| | | for (const key of Object.values(APPROVAL_MODULE_KEYS)) { |
| | | const types = getModuleMatchingBusinessTypes(key, typeOptions); |
| | | if (types.some(t => matchBusinessTypeValue(t, bt))) return key; |
| | | const cfg = APPROVAL_MODULE_REGISTRY[key]; |
| | | if (cfg && matchBusinessTypeValue(cfg.approvalType, bt)) return key; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** ç¼è¾åæ¾ï¼ä»å®ä¾è¡æ¢å¤æ©å±å段 */ |
| | | export function loadModuleExtrasFromRow(moduleKey, row, formPayload) { |
| | | const extras = { |
| | | leaveBalanceDays: undefined, |
| | | originalPostName: "", |
| | | }; |
| | | if (!moduleKey || !row) return extras; |
| | | |
| | | const payload = formPayload || {}; |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) { |
| | | const v = payload.leaveBalanceDays ?? row.leaveBalanceDays; |
| | | extras.leaveBalanceDays = |
| | | v != null && v !== "" ? Number(v) : undefined; |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) { |
| | | extras.originalPostName = |
| | | payload.originalPostName || |
| | | payload.originalPost || |
| | | row.originalPostName || |
| | | ""; |
| | | } |
| | | return extras; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { |
| | | APPROVAL_MODULE_KEYS, |
| | | APPROVAL_MODULE_REGISTRY, |
| | | getApprovalModuleConfig, |
| | | getModuleMatchingBusinessTypes, |
| | | } from "./approvalModuleRegistry.js"; |
| | | import { |
| | | parseApprovalFormConfig, |
| | | parseDatetimerangeValue, |
| | | } from "./approvalFormField.js"; |
| | | import { matchBusinessTypeValue } from "./approvalTemplateType.js"; |
| | | |
| | | /** ä¸ Web leave-apply LEAVE_TYPE_OPTIONS ä¸è´ */ |
| | | export 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" }, |
| | | ]; |
| | | |
| | | /** ä¸ Web overtime-apply OVERTIME_TYPE_OPTIONS ä¸è´ */ |
| | | export const OVERTIME_TYPE_OPTIONS = [ |
| | | { label: "工使¥å ç", value: "weekday" }, |
| | | { label: "伿¯æ¥å ç", value: "weekend" }, |
| | | { label: "æ³å®è忥å ç", value: "holiday" }, |
| | | ]; |
| | | |
| | | export const HANDOVER_STATUS_OPTIONS = [ |
| | | { label: "è¿è¡ä¸", value: "in_progress" }, |
| | | { label: "已宿", value: "completed" }, |
| | | { label: "å·²éå", value: "returned" }, |
| | | ]; |
| | | |
| | | export const HANDOVER_TYPE_OPTIONS = [ |
| | | { label: "离è交æ¥", value: "resignation" }, |
| | | { label: "è°å²äº¤æ¥", value: "transfer" }, |
| | | ]; |
| | | |
| | | function buildFormPayloadFromFields(fields = []) { |
| | | const payload = {}; |
| | | for (const f of fields) { |
| | | if (!f?.key) continue; |
| | | const val = f.value ?? f.defaultValue; |
| | | if (val !== undefined && val !== null && val !== "") { |
| | | payload[f.key] = val; |
| | | } |
| | | } |
| | | return payload; |
| | | } |
| | | |
| | | function guessFieldTypeFromValue(val) { |
| | | if (Array.isArray(val)) return "datetimerange"; |
| | | if (typeof val === "number") return "number"; |
| | | return "text"; |
| | | } |
| | | |
| | | /** è§£æå®ä¾ formConfig / formPayloadï¼ä¸ Web resolveInstanceFormFields 对é½ï¼ */ |
| | | export function resolveInstanceFormPayload(row) { |
| | | const cfg = parseApprovalFormConfig(row?.formConfig); |
| | | let fields = (row?.formFieldDefs?.length ? row.formFieldDefs : cfg.fields) || []; |
| | | const formPayload = { |
| | | ...(fields.length ? buildFormPayloadFromFields(fields) : {}), |
| | | ...cfg.formPayload, |
| | | ...(row?.formPayload || {}), |
| | | }; |
| | | if (!fields.length && Object.keys(formPayload).length) { |
| | | fields = Object.keys(formPayload) |
| | | .filter(k => k && k !== "summary") |
| | | .map(k => ({ |
| | | key: k, |
| | | label: k, |
| | | type: guessFieldTypeFromValue(formPayload[k]), |
| | | required: false, |
| | | options: [], |
| | | })); |
| | | } |
| | | fields = fields.map(field => ({ |
| | | ...field, |
| | | value: |
| | | formPayload[field.key] ?? field.value ?? field.defaultValue ?? "", |
| | | })); |
| | | return { fields, formPayload }; |
| | | } |
| | | |
| | | /** å·²ç¥ä¸æå段 value â å±ç¤ºææ¡ï¼æ¨¡æ¿æªå¸¦ options æ¶å
åºï¼ */ |
| | | export function formatKnownSelectLabel(prop, val) { |
| | | if (val === undefined || val === null || val === "") return ""; |
| | | const maps = { |
| | | leaveType: LEAVE_TYPE_OPTIONS, |
| | | overtimeType: OVERTIME_TYPE_OPTIONS, |
| | | handoverStatus: HANDOVER_STATUS_OPTIONS, |
| | | handoverType: HANDOVER_TYPE_OPTIONS, |
| | | }; |
| | | const options = maps[prop]; |
| | | if (!options) return ""; |
| | | const hit = options.find(o => String(o.value) === String(val)); |
| | | return hit?.label || ""; |
| | | } |
| | | |
| | | export function getRowPayloadValue(row, keys) { |
| | | const keyList = Array.isArray(keys) ? keys : [keys]; |
| | | const { formPayload } = resolveInstanceFormPayload(row); |
| | | for (const k of keyList) { |
| | | if (row?.[k] != null && row[k] !== "") return row[k]; |
| | | if (formPayload[k] != null && formPayload[k] !== "") return formPayload[k]; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | const DATETIME_RANGE_KEYS = [ |
| | | "dateRange", |
| | | "leaveTime", |
| | | "overtimeTime", |
| | | "timeRange", |
| | | ]; |
| | | |
| | | function pickDatetimerangeRaw(formPayload, fields = []) { |
| | | for (const key of DATETIME_RANGE_KEYS) { |
| | | const v = formPayload?.[key]; |
| | | if (v != null && v !== "") return v; |
| | | const field = (fields || []).find(f => f?.key === key); |
| | | const fv = field?.value ?? field?.defaultValue; |
| | | if (fv != null && fv !== "") return fv; |
| | | } |
| | | const rangeField = (fields || []).find( |
| | | f => String(f?.type || "").toLowerCase() === "datetimerange" |
| | | ); |
| | | if (rangeField?.key) { |
| | | const v = |
| | | formPayload?.[rangeField.key] ?? rangeField.value ?? rangeField.defaultValue; |
| | | if (v != null && v !== "") return v; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | function splitRangeValue(val) { |
| | | if (val === undefined || val === null || val === "") { |
| | | return { start: "", end: "" }; |
| | | } |
| | | if (Array.isArray(val)) { |
| | | return { start: val[0] || "", end: val[1] || "" }; |
| | | } |
| | | return parseDatetimerangeValue(val); |
| | | } |
| | | |
| | | /** |
| | | * å表å prop ä¸ formPayload 对é½ï¼è¯·å startTime/endTime æ¥èª dateRangeï¼ |
| | | */ |
| | | export function resolveListFieldRawValue(prop, row, fields = [], formPayload = {}) { |
| | | const payload = formPayload || {}; |
| | | const direct = payload[prop] ?? row?.[prop]; |
| | | |
| | | if (prop === "startTime" || prop === "endTime") { |
| | | if (direct != null && direct !== "") return direct; |
| | | const altStart = |
| | | payload.start ?? payload.startDate ?? payload.beginTime ?? row?.startTime; |
| | | const altEnd = |
| | | payload.end ?? payload.endDate ?? payload.finishTime ?? row?.endTime; |
| | | if (prop === "startTime" && altStart) return altStart; |
| | | if (prop === "endTime" && altEnd) return altEnd; |
| | | const { start, end } = splitRangeValue(pickDatetimerangeRaw(payload, fields)); |
| | | return prop === "startTime" ? start : end; |
| | | } |
| | | |
| | | if (prop === "overtimeDate") { |
| | | const d = payload.overtimeDate ?? payload.date ?? direct; |
| | | if (d != null && d !== "") return Array.isArray(d) ? d[0] || "" : d; |
| | | const { start } = splitRangeValue(pickDatetimerangeRaw(payload, fields)); |
| | | return start || ""; |
| | | } |
| | | |
| | | if (direct != null && direct !== "") return direct; |
| | | const hit = (fields || []).find(f => f?.key === prop); |
| | | return hit?.value ?? hit?.defaultValue ?? ""; |
| | | } |
| | | |
| | | function pickDateRange(searchForm) { |
| | | const range = |
| | | searchForm?.createTimeRange ?? |
| | | searchForm?.applyDateRange ?? |
| | | searchForm?.transferDateRange; |
| | | if (!Array.isArray(range) || !range[0]) return {}; |
| | | const out = { createTimeStart: range[0] }; |
| | | if (range[1]) out.createTimeEnd = range[1]; |
| | | return out; |
| | | } |
| | | |
| | | /** 忍¡åé»è®¤æ¥è¯¢è¡¨åï¼ä¸ Web searchForm åæ®µä¸è´ï¼ */ |
| | | export function createModuleSearchForm(moduleKey) { |
| | | switch (moduleKey) { |
| | | case APPROVAL_MODULE_KEYS.REGULAR: |
| | | return { applicantName: "", applyDateRange: null }; |
| | | case APPROVAL_MODULE_KEYS.TRANSFER: |
| | | return { applicantId: "", transferDateRange: null }; |
| | | case APPROVAL_MODULE_KEYS.WORK_HANDOVER: |
| | | return { applicantId: "", handoverStatus: "", handoverType: "" }; |
| | | case APPROVAL_MODULE_KEYS.LEAVE: |
| | | return { applicantKeyword: "", leaveType: "" }; |
| | | case APPROVAL_MODULE_KEYS.OVERTIME: |
| | | return { applicantKeyword: "", overtimeType: "" }; |
| | | case APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE: |
| | | case APPROVAL_MODULE_KEYS.COST_REIMBURSE: |
| | | case APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS: |
| | | return { applicantKeyword: "" }; |
| | | default: |
| | | return {}; |
| | | } |
| | | } |
| | | |
| | | /** æå¡ç«¯ listPage DTO çæ®µï¼ä¸ Web buildExtraListParams + buildApprovalInstanceListParams ä¸è´ï¼ */ |
| | | export function buildModuleListDto(moduleKey, searchForm = {}) { |
| | | const sf = searchForm || {}; |
| | | const dto = { ...pickDateRange(sf) }; |
| | | |
| | | switch (moduleKey) { |
| | | case APPROVAL_MODULE_KEYS.REGULAR: { |
| | | const name = (sf.applicantName || "").trim(); |
| | | if (name) dto.applicantName = name; |
| | | break; |
| | | } |
| | | case APPROVAL_MODULE_KEYS.TRANSFER: |
| | | break; |
| | | case APPROVAL_MODULE_KEYS.WORK_HANDOVER: |
| | | break; |
| | | case APPROVAL_MODULE_KEYS.LEAVE: |
| | | case APPROVAL_MODULE_KEYS.OVERTIME: |
| | | break; |
| | | default: |
| | | break; |
| | | } |
| | | return dto; |
| | | } |
| | | |
| | | function matchApplicantKeyword(row, keyword) { |
| | | const kw = (keyword || "").trim().toLowerCase(); |
| | | if (!kw) return true; |
| | | const parts = [ |
| | | row?.applicantName, |
| | | row?.applicantNo, |
| | | row?.applicantId, |
| | | getRowPayloadValue(row, ["applicant", "applicantName", "applicantId"]), |
| | | ] |
| | | .filter(v => v != null && v !== "") |
| | | .map(v => String(v).toLowerCase()); |
| | | return parts.some(p => p.includes(kw)); |
| | | } |
| | | |
| | | function matchSelectValue(row, keys, expected) { |
| | | if (!expected) return true; |
| | | const raw = getRowPayloadValue(row, keys); |
| | | return String(raw) === String(expected); |
| | | } |
| | | |
| | | function matchApplicantId(row, applicantId) { |
| | | if (!applicantId) return true; |
| | | const id = String(applicantId); |
| | | if (row?.applicantId != null && String(row.applicantId) === id) return true; |
| | | const payloadApplicant = getRowPayloadValue(row, [ |
| | | "applicant", |
| | | "applicantId", |
| | | "applicantUserId", |
| | | ]); |
| | | return String(payloadApplicant) === id; |
| | | } |
| | | |
| | | /** ææ¨¡å businessType / æ é¢å½å±è¿æ»¤ï¼æå¡ç«¯æªçææ¶çå
åºï¼ */ |
| | | export function filterRowsByModuleBusinessType(moduleKey, rows, typeOptions = []) { |
| | | const cfg = getApprovalModuleConfig(moduleKey); |
| | | if (!cfg) return rows; |
| | | |
| | | const types = getModuleMatchingBusinessTypes(moduleKey, typeOptions); |
| | | const myLabels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean); |
| | | |
| | | return (rows || []).filter(row => { |
| | | if (types.length && row?.businessType != null && row.businessType !== "") { |
| | | if (types.some(t => matchBusinessTypeValue(row.businessType, t))) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | const title = String(row?.title || row?.templateName || "").trim(); |
| | | if (title) { |
| | | if (myLabels.some(l => title === l || title.includes(l))) return true; |
| | | for (const [key, other] of Object.entries(APPROVAL_MODULE_REGISTRY)) { |
| | | if (key === moduleKey) continue; |
| | | const otherLabels = [other.label, ...(other.typeLabels || [])].filter(Boolean); |
| | | if (otherLabels.some(l => title === l || (l.length > 2 && title.includes(l)))) { |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return types.length === 0; |
| | | }); |
| | | } |
| | | |
| | | /** å端çéï¼Web æªä¸åæ¥å£çåæ®µä¸ Web è¡ä¸ºä¸è´ï¼ */ |
| | | export function filterRowsByModuleSearch(moduleKey, rows, searchForm = {}) { |
| | | const sf = searchForm || {}; |
| | | const list = Array.isArray(rows) ? rows : []; |
| | | |
| | | switch (moduleKey) { |
| | | case APPROVAL_MODULE_KEYS.TRANSFER: |
| | | return list.filter( |
| | | row => |
| | | matchApplicantId(row, sf.applicantId) && |
| | | matchApplicantKeyword(row, sf.applicantKeyword) |
| | | ); |
| | | case APPROVAL_MODULE_KEYS.WORK_HANDOVER: |
| | | return list.filter( |
| | | row => |
| | | matchApplicantId(row, sf.applicantId) && |
| | | matchSelectValue(row, ["handoverStatus", "交æ¥ç¶æ"], sf.handoverStatus) && |
| | | matchSelectValue(row, ["handoverType", "交æ¥ç±»å"], sf.handoverType) |
| | | ); |
| | | case APPROVAL_MODULE_KEYS.LEAVE: |
| | | return list.filter( |
| | | row => |
| | | matchApplicantKeyword(row, sf.applicantKeyword) && |
| | | matchSelectValue(row, ["leaveType", "请åç±»å"], sf.leaveType) |
| | | ); |
| | | case APPROVAL_MODULE_KEYS.OVERTIME: |
| | | return list.filter( |
| | | row => |
| | | matchApplicantKeyword(row, sf.applicantKeyword) && |
| | | matchSelectValue(row, ["overtimeType", "å çç±»å"], sf.overtimeType) |
| | | ); |
| | | case APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE: |
| | | case APPROVAL_MODULE_KEYS.COST_REIMBURSE: |
| | | case APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS: |
| | | return list.filter(row => matchApplicantKeyword(row, sf.applicantKeyword)); |
| | | default: |
| | | return list; |
| | | } |
| | | } |
| | | |
| | | /** 模åçé UI é
ç½® */ |
| | | export function getModuleSearchMeta(moduleKey) { |
| | | switch (moduleKey) { |
| | | case APPROVAL_MODULE_KEYS.REGULAR: |
| | | return { |
| | | fields: [ |
| | | { key: "applicantName", type: "input", label: "ç³è¯·äºº", placeholder: "请è¾å
¥ç³è¯·äºº" }, |
| | | { key: "applyDateRange", type: "daterange", label: "ç³è¯·æ¥æ" }, |
| | | ], |
| | | }; |
| | | case APPROVAL_MODULE_KEYS.TRANSFER: |
| | | return { |
| | | fields: [ |
| | | { key: "applicantId", type: "user", label: "ç³è¯·äºº", placeholder: "è¯·éæ©ç³è¯·äºº" }, |
| | | { key: "transferDateRange", type: "daterange", label: "è½¬å²æ¶é´" }, |
| | | ], |
| | | }; |
| | | case APPROVAL_MODULE_KEYS.WORK_HANDOVER: |
| | | return { |
| | | fields: [ |
| | | { key: "applicantId", type: "user", label: "ç³è¯·äºº", placeholder: "è¯·éæ©ç³è¯·äºº" }, |
| | | { |
| | | key: "handoverStatus", |
| | | type: "select", |
| | | label: "交æ¥ç¶æ", |
| | | options: HANDOVER_STATUS_OPTIONS, |
| | | }, |
| | | { |
| | | key: "handoverType", |
| | | type: "select", |
| | | label: "交æ¥ç±»å", |
| | | options: HANDOVER_TYPE_OPTIONS, |
| | | }, |
| | | ], |
| | | }; |
| | | case APPROVAL_MODULE_KEYS.LEAVE: |
| | | return { |
| | | fields: [ |
| | | { |
| | | key: "applicantKeyword", |
| | | type: "input", |
| | | label: "ç³è¯·äºº", |
| | | placeholder: "å§åæç¼å·", |
| | | }, |
| | | { |
| | | key: "leaveType", |
| | | type: "select", |
| | | label: "请åç±»å", |
| | | options: LEAVE_TYPE_OPTIONS, |
| | | }, |
| | | ], |
| | | }; |
| | | case APPROVAL_MODULE_KEYS.OVERTIME: |
| | | return { |
| | | fields: [ |
| | | { |
| | | key: "applicantKeyword", |
| | | type: "input", |
| | | label: "ç³è¯·äºº", |
| | | placeholder: "å§åæç¼å·", |
| | | }, |
| | | { |
| | | key: "overtimeType", |
| | | type: "select", |
| | | label: "å çç±»å", |
| | | options: OVERTIME_TYPE_OPTIONS, |
| | | }, |
| | | ], |
| | | }; |
| | | case APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE: |
| | | case APPROVAL_MODULE_KEYS.COST_REIMBURSE: |
| | | case APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS: |
| | | return { |
| | | fields: [ |
| | | { |
| | | key: "applicantKeyword", |
| | | type: "input", |
| | | label: "ç³è¯·äºº", |
| | | placeholder: "å§åæç¼å·", |
| | | }, |
| | | ], |
| | | }; |
| | | default: |
| | | return { fields: [] }; |
| | | } |
| | | } |
| | | |
| | | export function resetModuleSearchForm(moduleKey, target) { |
| | | const defaults = createModuleSearchForm(moduleKey); |
| | | Object.keys(target).forEach(k => { |
| | | if (!(k in defaults)) delete target[k]; |
| | | }); |
| | | Object.assign(target, defaults); |
| | | } |
| | | |
| | | export function formatDateRangeLabel(range) { |
| | | if (!Array.isArray(range) || !range[0]) return ""; |
| | | if (range[1]) return `${range[0]} è³ ${range[1]}`; |
| | | return range[0]; |
| | | } |
| | | |
| | | export 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 ?? ""}`; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | |
| | | /** ä¸ Web approvalModuleRegistry ä¸è´ */ |
| | | export const APPROVAL_MODULE_KEYS = { |
| | | REGULAR: "regular", |
| | | TRANSFER: "transfer", |
| | | WORK_HANDOVER: "work_handover", |
| | | LEAVE: "leave", |
| | | OVERTIME: "overtime", |
| | | TRAVEL_REIMBURSE: "travel_reimburse", |
| | | COST_REIMBURSE: "cost_reimburse", |
| | | ENTERPRISE_NEWS: "enterprise_news", |
| | | }; |
| | | |
| | | /** 审æ¹å®ä¾ listPage businessTypeï¼ä¸å端约å®ä¸è´ï¼ */ |
| | | export const APPROVAL_BUSINESS_TYPE = { |
| | | [APPROVAL_MODULE_KEYS.REGULAR]: 10, |
| | | [APPROVAL_MODULE_KEYS.TRANSFER]: 11, |
| | | [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: 13, |
| | | [APPROVAL_MODULE_KEYS.LEAVE]: 14, |
| | | [APPROVAL_MODULE_KEYS.OVERTIME]: 15, |
| | | [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: 16, |
| | | [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: 17, |
| | | [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: 18, |
| | | }; |
| | | |
| | | export const APPROVAL_MODULE_REGISTRY = { |
| | | [APPROVAL_MODULE_KEYS.REGULAR]: { |
| | | label: "转æ£ç³è¯·", |
| | | approvalType: "regular", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.REGULAR], |
| | | typeLabels: ["转æ£", "转æ£ç³è¯·"], |
| | | listFields: [ |
| | | { label: "å
¥èæ¥æ", prop: "entryDate" }, |
| | | { label: "è½¬æ£æ¥æ", prop: "regularDate" }, |
| | | ], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.TRANSFER]: { |
| | | label: "è°å²ç³è¯·", |
| | | approvalType: "transfer", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRANSFER], |
| | | typeLabels: ["è°å²", "è°å¨", "è°å²ç³è¯·", "è°å¨ç³è¯·"], |
| | | listFields: [ |
| | | { label: "åå²ä½", prop: "fromPost" }, |
| | | { label: "ç®æ å²ä½", prop: "toPost" }, |
| | | ], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: { |
| | | label: "å·¥ä½äº¤æ¥", |
| | | approvalType: "work_handover", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.WORK_HANDOVER], |
| | | typeLabels: ["å·¥ä½äº¤æ¥", "交æ¥", "å·¥ä½äº¤æ¥å®¡æ¹"], |
| | | listFields: [ |
| | | { label: "交æ¥äºº", prop: "handoverTo" }, |
| | | { label: "交æ¥äºé¡¹", prop: "handoverItems" }, |
| | | ], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.LEAVE]: { |
| | | label: "请åç³è¯·", |
| | | approvalType: "leave", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.LEAVE], |
| | | typeLabels: ["请å", "请åç³è¯·", "请å审æ¹"], |
| | | listFields: [ |
| | | { label: "请åç±»å", prop: "leaveType" }, |
| | | { label: "å¼å§æ¶é´", prop: "startTime" }, |
| | | { label: "ç»ææ¶é´", prop: "endTime" }, |
| | | ], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.OVERTIME]: { |
| | | label: "å çç³è¯·", |
| | | approvalType: "overtime", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OVERTIME], |
| | | typeLabels: ["å ç", "å çç³è¯·", "å ç审æ¹"], |
| | | listFields: [ |
| | | { label: "å çæ¥æ", prop: "overtimeDate" }, |
| | | { label: "æ¶é¿(å°æ¶)", prop: "hours" }, |
| | | ], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: { |
| | | label: "å·®æ
æ¥é", |
| | | approvalType: "travel_reimburse", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE], |
| | | typeLabels: ["å·®æ
", "å·®æ
æ¥é", "åºå·®æ¥é"], |
| | | listFields: [], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: { |
| | | label: "è´¹ç¨æ¥é", |
| | | approvalType: "cost_reimburse", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.COST_REIMBURSE], |
| | | typeLabels: ["è´¹ç¨", "è´¹ç¨æ¥é"], |
| | | listFields: [], |
| | | }, |
| | | [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: { |
| | | label: "ä¼ä¸æ°é»", |
| | | approvalType: "enterprise_news", |
| | | businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS], |
| | | typeLabels: ["ä¼ä¸æ°é»", "æ°é»", "æ°é»åå¸"], |
| | | listFields: [], |
| | | }, |
| | | }; |
| | | |
| | | export function getApprovalModuleConfig(moduleKey) { |
| | | if (!moduleKey) return null; |
| | | return APPROVAL_MODULE_REGISTRY[moduleKey] || null; |
| | | } |
| | | |
| | | export function getModuleListBusinessType(moduleKey) { |
| | | const cfg = getApprovalModuleConfig(moduleKey); |
| | | if (!cfg) return ""; |
| | | if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType; |
| | | return APPROVAL_BUSINESS_TYPE[moduleKey] ?? ""; |
| | | } |
| | | |
| | | function matchBiz(a, b) { |
| | | if (a == null || a === "" || b == null || b === "") return false; |
| | | return a === b || a === Number(b) || Number(a) === b || String(a) === String(b); |
| | | } |
| | | |
| | | export function resolveModuleBusinessType(moduleKey, typeOptions = []) { |
| | | const cfg = getApprovalModuleConfig(moduleKey); |
| | | if (!cfg) return null; |
| | | |
| | | const fixed = getModuleListBusinessType(moduleKey); |
| | | if (fixed != null && fixed !== "") return fixed; |
| | | |
| | | const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean); |
| | | const hitByLabel = (typeOptions || []).find(opt => { |
| | | const optLabel = String(opt?.name || opt?.label || "").trim(); |
| | | if (!optLabel) return false; |
| | | return labels.some( |
| | | l => optLabel === l || optLabel.includes(l) || l.includes(optLabel) |
| | | ); |
| | | }); |
| | | if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value; |
| | | |
| | | if (cfg.approvalType) { |
| | | const hitByValue = (typeOptions || []).find( |
| | | opt => |
| | | matchBiz(opt?.value, cfg.approvalType) || matchBiz(opt?.code, cfg.approvalType) |
| | | ); |
| | | if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) { |
| | | const cfg = getApprovalModuleConfig(moduleKey); |
| | | if (!cfg) return []; |
| | | |
| | | const fixed = getModuleListBusinessType(moduleKey); |
| | | if (fixed != null && fixed !== "") return [fixed]; |
| | | |
| | | const values = new Set(); |
| | | const primary = resolveModuleBusinessType(moduleKey, typeOptions); |
| | | if (primary != null && primary !== "") values.add(primary); |
| | | |
| | | const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean); |
| | | for (const opt of typeOptions || []) { |
| | | const optLabel = String(opt?.name || opt?.label || "").trim(); |
| | | if (!optLabel) continue; |
| | | const matched = labels.some( |
| | | l => optLabel === l || optLabel.includes(l) || l.includes(optLabel) |
| | | ); |
| | | if (matched && opt.value != null && opt.value !== "") { |
| | | values.add(opt.value); |
| | | } |
| | | } |
| | | return [...values]; |
| | | } |
| | | |
| | | export const PAGE_KEY_TO_MODULE = { |
| | | "HrManage/regular-apply": APPROVAL_MODULE_KEYS.REGULAR, |
| | | "HrManage/transfer-apply": APPROVAL_MODULE_KEYS.TRANSFER, |
| | | "HrManage/work-handover": APPROVAL_MODULE_KEYS.WORK_HANDOVER, |
| | | "AttendManage/leave-apply": APPROVAL_MODULE_KEYS.LEAVE, |
| | | "AttendManage/overtime-apply": APPROVAL_MODULE_KEYS.OVERTIME, |
| | | "ReimburseManage/travel-reimburse": APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE, |
| | | "ReimburseManage/cost-reimburse": APPROVAL_MODULE_KEYS.COST_REIMBURSE, |
| | | "EnterpriseNews/news-manage": APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS, |
| | | }; |
| | | |
| | | export function getModuleKeyFromPageKey(pageKey) { |
| | | return PAGE_KEY_TO_MODULE[pageKey] || ""; |
| | | } |
| | |
| | | import { getTypeEnums } from "@/api/basic/enum.js"; |
| | | import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js"; |
| | | |
| | | /** |
| | | * GET /approvalTemplate/list/{type} è·¯å¾åæ°ä¸º templateType |
| | |
| | | */ |
| | | export const CUSTOM_TEMPLATE_LIST_TYPE = 1; |
| | | |
| | | /** ç³»ç»å
置模æ¿ï¼ä¸å¯å é¤ï¼å¡«æ¥é¡¹çåéï¼ */ |
| | | export const SYSTEM_TEMPLATE_TYPE = 0; |
| | | |
| | | export function isSystemApprovalTemplate(item) { |
| | | return Number(item?.templateType) === SYSTEM_TEMPLATE_TYPE; |
| | | } |
| | | |
| | | /** ä¸å¡ç±»åæä¸¾å
åºï¼approveTypeï¼1å
¬åº 2请å â¦ï¼ */ |
| | | export const FALLBACK_BUSINESS_TYPE_OPTIONS = [ |
| | | { name: "å
¬åºç®¡ç", value: 1 }, |
| | | { name: "请å管ç", value: 2 }, |
| | | ]; |
| | | |
| | | /** è§£æ TypeEnums æ¥å£è¿åï¼å
¼å®¹ { TypeEnums: [] } åµå¥ï¼ */ |
| | | export function unwrapEnumList(data) { |
| | | if (Array.isArray(data)) return data; |
| | | if (!data || typeof data !== "object") return []; |
| | | if (Array.isArray(data.TypeEnums)) return data.TypeEnums; |
| | | if (Array.isArray(data.typeEnums)) return data.typeEnums; |
| | | const nested = Object.values(data).find(v => Array.isArray(v)); |
| | | return nested || []; |
| | | } |
| | | |
| | | /** è§£ææ¨¡æ¿å表æ¥å£è¿å */ |
| | | export function unwrapTemplateList(payload) { |
| | | const data = payload?.data ?? payload; |
| | | if (Array.isArray(data)) return data; |
| | | if (Array.isArray(data?.records)) return data.records; |
| | | if (Array.isArray(data?.list)) return data.list; |
| | | if (Array.isArray(data?.rows)) return data.rows; |
| | | return []; |
| | | } |
| | | |
| | | /** enabledï¼1 / true 为å¯ç¨ï¼ä¸ Web mapEnabledFromApi ä¸è´ï¼ */ |
| | | export function mapEnabledFromApi(enabled) { |
| | | if (enabled === undefined || enabled === null) return true; |
| | | if (enabled === true || enabled === 1) return true; |
| | | const s = String(enabled).toLowerCase(); |
| | | return s === "1" || s === "true" || s === "yes"; |
| | | } |
| | | |
| | | export function filterEnabledTemplates(list) { |
| | | return (list || []).filter(row => mapEnabledFromApi(row?.enabled)); |
| | | } |
| | | |
| | | /** å° /basic/enum/TypeEnums ååºè§è为 { name, value }[] */ |
| | | export function normalizeEnumOptions(data) { |
| | | if (!data) return []; |
| | | |
| | | if (Array.isArray(data)) { |
| | | return data |
| | | .map(item => { |
| | | const name = |
| | | item?.name ?? |
| | | item?.label ?? |
| | | item?.text ?? |
| | | item?.dictLabel ?? |
| | | item?.description; |
| | | const rawValue = |
| | | item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id; |
| | | if (name == null || rawValue === undefined || rawValue === null) { |
| | | return null; |
| | | } |
| | | const num = Number(rawValue); |
| | | return { |
| | | name: String(name), |
| | | value: Number.isNaN(num) ? rawValue : num, |
| | | }; |
| | | }) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | if (typeof data === "object") { |
| | | return Object.entries(data).map(([value, name]) => { |
| | | const num = Number(value); |
| | | return unwrapEnumList(data) |
| | | .map(item => { |
| | | const name = |
| | | item?.name ?? |
| | | item?.label ?? |
| | | item?.text ?? |
| | | item?.dictLabel ?? |
| | | item?.description; |
| | | const rawValue = |
| | | item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id; |
| | | if (name == null || rawValue === undefined || rawValue === null) { |
| | | return null; |
| | | } |
| | | const num = Number(rawValue); |
| | | return { |
| | | name: String(name), |
| | | value: Number.isNaN(num) ? value : num, |
| | | value: Number.isNaN(num) ? rawValue : num, |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | return []; |
| | | }) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | /** æåä¸å¡ç±»åæä¸¾ï¼TypeEnums â businessTypeï¼ */ |
| | | export async function fetchApprovalTemplateTypes() { |
| | | const res = await getTypeEnums(); |
| | | const options = normalizeEnumOptions(res?.data); |
| | | return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS]; |
| | | try { |
| | | const res = await getTypeEnums(); |
| | | const options = normalizeEnumOptions(res?.data ?? res); |
| | | return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS]; |
| | | } catch { |
| | | return [...FALLBACK_BUSINESS_TYPE_OPTIONS]; |
| | | } |
| | | } |
| | | |
| | | /** éå第ä¸ä¸ªãææ¨¡æ¿ãç Tab 䏿 */ |
| | | export function pickTabIndexWithTemplates(typeOptions, templates) { |
| | | if (!typeOptions?.length) return 0; |
| | | for (let i = 0; i < typeOptions.length; i++) { |
| | | const bt = typeOptions[i]?.value; |
| | | if (filterTemplatesByBusinessType(templates, bt).length > 0) return i; |
| | | } |
| | | return 0; |
| | | } |
| | | |
| | | /** æåå·²å¯ç¨æ¨¡æ¿ï¼èªå®ä¹ + ç³»ç»å
ç½®ï¼ä¸ Web 导å
¥é»è¾ä¸è´ï¼ */ |
| | | export async function fetchEnabledApprovalTemplates() { |
| | | const [customRes, builtinRes] = await Promise.all([ |
| | | listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE), |
| | | listApprovalTemplateByType(SYSTEM_TEMPLATE_TYPE), |
| | | ]); |
| | | const merged = [ |
| | | ...unwrapTemplateList(customRes), |
| | | ...unwrapTemplateList(builtinRes), |
| | | ]; |
| | | const byId = new Map(); |
| | | merged.forEach(item => { |
| | | if (item?.id != null) byId.set(String(item.id), item); |
| | | }); |
| | | return filterEnabledTemplates([...byId.values()]); |
| | | } |
| | | |
| | | /** businessType 宽æ¾å¹é
ï¼ä¸ Web matchBusinessTypeValue ä¸è´ï¼ */ |
| | | export function matchBusinessTypeValue(a, b) { |
| | | if (a == null || a === "" || b == null || b === "") return false; |
| | | return a === b || a === Number(b) || Number(a) === b || String(a) === String(b); |
| | | } |
| | | |
| | | /** æ businessType ç鿍¡æ¿ */ |
| | | export function filterTemplatesByBusinessType(templates, businessType) { |
| | | if (businessType == null || businessType === "") return []; |
| | | return (templates || []).filter( |
| | | item => String(item.businessType) === String(businessType) |
| | | return (templates || []).filter(item => |
| | | matchBusinessTypeValue(item.businessType, businessType) |
| | | ); |
| | | } |
| | | |
| | | /** æå¤ä¸ª businessType çéï¼ä¸å¡æ¨¡åï¼ */ |
| | | export function filterTemplatesByBusinessTypes(templates, businessTypes = []) { |
| | | const types = (businessTypes || []).filter(t => t != null && t !== ""); |
| | | if (!types.length) return []; |
| | | return (templates || []).filter(item => |
| | | types.some(t => matchBusinessTypeValue(item.businessType, t)) |
| | | ); |
| | | } |
| | | |
| | |
| | | return idx >= 0 ? idx : 0; |
| | | } |
| | | |
| | | /** TypeEnums 为空æ¶ï¼ä»æ¨¡æ¿åè¡¨åæ¨ Tab */ |
| | | export function buildTypeOptionsFromTemplates(templates) { |
| | | const map = new Map(); |
| | | (templates || []).forEach(item => { |
| | | const v = item?.businessType; |
| | | if (v == null || v === "") return; |
| | | const key = String(v); |
| | | if (!map.has(key)) { |
| | | map.set(key, { name: `审æ¹ç±»å ${key}`, value: v }); |
| | | } |
| | | }); |
| | | return [...map.values()]; |
| | | } |
| | | |
| | | export function buildTypeLabelMap(options) { |
| | | const map = {}; |
| | | (options || []).forEach(opt => { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { |
| | | formatFieldDisplayValue, |
| | | getFieldOptionLabel, |
| | | isSelectField, |
| | | mergeFormConfigForEdit, |
| | | } from "./approvalFormField.js"; |
| | | import { |
| | | formatKnownSelectLabel, |
| | | resolveInstanceFormPayload, |
| | | resolveListFieldRawValue, |
| | | } from "./approvalModuleListSearch.js"; |
| | | |
| | | export const DETAIL_STORAGE_KEY = "oa_approve_instance_detail_row"; |
| | | |
| | | export const INSTANCE_STATUS_TEXT = { |
| | | PENDING: "è¿è¡ä¸", |
| | | APPROVED: "å·²éè¿", |
| | | REJECTED: "已驳å", |
| | | DRAFT: "è稿", |
| | | }; |
| | | |
| | | export const INSTANCE_STATUS_TAG = { |
| | | PENDING: "warning", |
| | | APPROVED: "success", |
| | | REJECTED: "error", |
| | | DRAFT: "info", |
| | | }; |
| | | |
| | | export const TASK_STATUS_TEXT = { |
| | | PENDING: "å¾
å¤ç", |
| | | APPROVED: "å·²éè¿", |
| | | REJECTED: "已驳å", |
| | | }; |
| | | |
| | | export const TASK_STATUS_TAG = { |
| | | PENDING: "warning", |
| | | APPROVED: "success", |
| | | REJECTED: "error", |
| | | }; |
| | | |
| | | export function instanceStatusText(status) { |
| | | return INSTANCE_STATUS_TEXT[status] || status || "-"; |
| | | } |
| | | |
| | | export function instanceStatusTagType(status) { |
| | | return INSTANCE_STATUS_TAG[status] || "info"; |
| | | } |
| | | |
| | | export function taskStatusText(status) { |
| | | const key = String(status || "").toUpperCase(); |
| | | return TASK_STATUS_TEXT[key] || status || "å¾
å¤ç"; |
| | | } |
| | | |
| | | export function taskStatusTagType(status) { |
| | | const key = String(status || "").toUpperCase(); |
| | | return TASK_STATUS_TAG[key] || "info"; |
| | | } |
| | | |
| | | export function formatDateTime(val) { |
| | | if (!val) return "-"; |
| | | return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val); |
| | | } |
| | | |
| | | /** è§£æå®ä¾ä¸ºåªè¯»å±ç¤ºå段ï¼åå¹¶ formPayloadï¼æ¯æä¼ æ´è¡æä»
formConfigï¼ */ |
| | | export function resolveInstanceDisplayFields(formConfigOrRow) { |
| | | const row = |
| | | formConfigOrRow && |
| | | typeof formConfigOrRow === "object" && |
| | | (formConfigOrRow.formConfig != null || |
| | | formConfigOrRow.formPayload != null || |
| | | formConfigOrRow.formFieldDefs != null) |
| | | ? formConfigOrRow |
| | | : { formConfig: formConfigOrRow }; |
| | | const { fields } = resolveInstanceFormPayload(row); |
| | | if (fields.length) return fields.filter(f => f?.key); |
| | | const merged = mergeFormConfigForEdit("", row.formConfig); |
| | | return (merged.fields || []).filter(f => f?.key); |
| | | } |
| | | |
| | | export function displayFieldValue(field) { |
| | | const val = field.value ?? field.defaultValue; |
| | | if (val === undefined || val === null || val === "") return "-"; |
| | | if (isSelectField(field)) { |
| | | const fromOptions = getFieldOptionLabel(field, val); |
| | | if (fromOptions && fromOptions !== "-") return fromOptions; |
| | | const known = formatKnownSelectLabel(field.key, val); |
| | | if (known) return known; |
| | | return String(val); |
| | | } |
| | | const known = formatKnownSelectLabel(field?.key, val); |
| | | if (known) return known; |
| | | const shown = formatFieldDisplayValue(field, val); |
| | | return shown || String(val); |
| | | } |
| | | |
| | | const DATETIME_LIST_PROPS = new Set([ |
| | | "startTime", |
| | | "endTime", |
| | | "overtimeDate", |
| | | "applyTime", |
| | | ]); |
| | | |
| | | function formatListFieldDisplay(prop, val, field) { |
| | | if (val === undefined || val === null || val === "") return "-"; |
| | | if (DATETIME_LIST_PROPS.has(prop)) { |
| | | const shown = formatDateTime(val); |
| | | if (shown && shown !== "-") return shown; |
| | | } |
| | | if (field?.type === "datetimerange") { |
| | | const shown = formatFieldDisplayValue(field, val); |
| | | if (shown) return shown; |
| | | } |
| | | if (field) return displayFieldValue({ ...field, value: val }); |
| | | const known = formatKnownSelectLabel(prop, val); |
| | | if (known) return known; |
| | | return String(val); |
| | | } |
| | | |
| | | /** 审æ¹è®°å½ resultï¼approved | rejected | pending */ |
| | | export function mapRecordResult(action) { |
| | | const s = String(action || "").toUpperCase(); |
| | | if (s === "APPROVED" || s === "APPROVE" || s === "PASS") return "approved"; |
| | | if (s === "REJECTED" || s === "REJECT" || s === "REFUSE") return "rejected"; |
| | | return "pending"; |
| | | } |
| | | |
| | | export function recordActionLabel(result) { |
| | | if (result === "approved") return "éè¿"; |
| | | if (result === "rejected") return "驳å"; |
| | | return "å¾
å¤ç"; |
| | | } |
| | | |
| | | export function mapApprovalRecords(records) { |
| | | const list = Array.isArray(records) ? records : []; |
| | | return list.map((r, index) => ({ |
| | | id: r.id ?? index, |
| | | operatorName: r.approverName || r.operatorName || r.createUserName || "â", |
| | | result: mapRecordResult(r.approveAction ?? r.action ?? r.status), |
| | | opinion: r.approveComment || r.comment || r.opinion || "", |
| | | time: formatDateTime(r.approveTime || r.createTime || r.time), |
| | | })); |
| | | } |
| | | |
| | | export function getRejectReasonFromRecords(records) { |
| | | const mapped = mapApprovalRecords(records); |
| | | const hit = mapped.find(r => r.result === "rejected"); |
| | | return hit?.opinion || ""; |
| | | } |
| | | |
| | | /** å表 tasks â æµç¨èç¹ï¼ä¸ apply 页èç¹ç»ææ¥è¿ï¼ */ |
| | | export function mapTasksToFlowNodes(tasks) { |
| | | const list = Array.isArray(tasks) ? tasks : []; |
| | | if (!list.length) return []; |
| | | |
| | | const byLevel = new Map(); |
| | | list.forEach(t => { |
| | | const level = Number(t.levelNo ?? t.taskLevel ?? t.nodeOrder ?? 1); |
| | | if (!byLevel.has(level)) { |
| | | byLevel.set(level, { |
| | | levelNo: level, |
| | | approveType: t.approveType || "AND", |
| | | approvers: [], |
| | | }); |
| | | } |
| | | const node = byLevel.get(level); |
| | | node.approvers.push({ |
| | | approverName: t.approverName || "â", |
| | | taskStatus: t.taskStatus ?? t.status, |
| | | approveComment: t.approveComment, |
| | | approveTime: t.approveTime, |
| | | }); |
| | | if (t.approveType) node.approveType = t.approveType; |
| | | }); |
| | | |
| | | return [...byLevel.entries()] |
| | | .sort(([a], [b]) => a - b) |
| | | .map(([, node]) => node); |
| | | } |
| | | |
| | | /** ç»è£
å®¡æ¹æäº¤ DTOï¼ä¸ Web buildApproveInstanceDto ä¸è´ï¼ */ |
| | | export function buildApproveInstanceDto(id, uiResult, comment) { |
| | | const opinion = (comment || "").trim(); |
| | | return { |
| | | id, |
| | | approveAction: uiResult === "rejected" ? "REJECTED" : "APPROVED", |
| | | approveComment: opinion || (uiResult === "approved" ? "åæ" : ""), |
| | | }; |
| | | } |
| | | |
| | | /** æ¯å¦æ¬äººåèµ·çå®¡æ¹ */ |
| | | export function isOwnApplication(item, userStore) { |
| | | const uid = userStore?.id; |
| | | if (item?.applicantId != null && uid != null && uid !== "") { |
| | | return String(item.applicantId) === String(uid); |
| | | } |
| | | const loginName = userStore?.nickName || userStore?.name; |
| | | if (loginName && item?.applicantName) { |
| | | return String(item.applicantName).trim() === String(loginName).trim(); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** ä»
è¿è¡ä¸ä¸æ¬äººåèµ·æ¶å¯ç¼è¾ */ |
| | | export function canModifyInstance(item, userStore) { |
| | | return item?.status === "PENDING" && isOwnApplication(item, userStore); |
| | | } |
| | | |
| | | /** å¾
å½åç¨æ·å®¡æ¹ */ |
| | | export function canApproveInstance(item) { |
| | | return Boolean(item?.isApprove) && item?.status === "PENDING"; |
| | | } |
| | | |
| | | export function stashInstanceRow(item) { |
| | | if (item) { |
| | | uni.setStorageSync(DETAIL_STORAGE_KEY, item); |
| | | } |
| | | } |
| | | |
| | | export function loadInstanceRow(id) { |
| | | const row = uni.getStorageSync(DETAIL_STORAGE_KEY); |
| | | if (!row || String(row.id) !== String(id)) return null; |
| | | return row; |
| | | } |
| | | |
| | | export const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row"; |
| | | |
| | | /** ä¸å¡ç³è¯·é¡µç¶æï¼è¿è¡ä¸/已宿ä¸å¯ä¿®æ¹ï¼ä¸ Web canEditBusinessInstanceRow ä¸è´ï¼ */ |
| | | export function normalizeApprovalStatusKey(v) { |
| | | if (v == null || v === "") return "pending"; |
| | | const upper = String(v).trim().toUpperCase(); |
| | | if (upper === "DRAFT") return "draft"; |
| | | if (upper === "APPROVED" || upper === "PASS") return "approved"; |
| | | if (upper === "REJECTED" || upper === "REJECT" || upper === "REFUSE") { |
| | | return "rejected"; |
| | | } |
| | | if (upper === "CANCELLED" || upper === "CANCEL") return "cancelled"; |
| | | if (upper === "PENDING" || upper === "IN_PROGRESS") return "pending"; |
| | | const lower = String(v).trim().toLowerCase(); |
| | | if (["draft", "pending", "approved", "rejected", "cancelled"].includes(lower)) { |
| | | return lower; |
| | | } |
| | | return "pending"; |
| | | } |
| | | |
| | | export function canEditBusinessInstanceRow(row) { |
| | | const key = normalizeApprovalStatusKey(row?.status ?? row?.approvalStatus); |
| | | return key !== "pending" && key !== "approved"; |
| | | } |
| | | |
| | | export function businessStatusText(status) { |
| | | const key = normalizeApprovalStatusKey(status); |
| | | if (key === "draft") return "è稿"; |
| | | if (key === "pending") return "è¿è¡ä¸"; |
| | | if (key === "approved") return "已宿"; |
| | | if (key === "rejected") return "已驳å"; |
| | | if (key === "cancelled") return "å·²æ¤é"; |
| | | return instanceStatusText(status); |
| | | } |
| | | |
| | | export function businessStatusTagType(status) { |
| | | const key = normalizeApprovalStatusKey(status); |
| | | if (key === "approved") return "success"; |
| | | if (key === "rejected") return "error"; |
| | | if (key === "draft" || key === "cancelled") return "info"; |
| | | return "warning"; |
| | | } |
| | | |
| | | /** OA å表èªå®ä¹ç¶æè§æ class */ |
| | | export function businessStatusClass(status) { |
| | | return `status-${normalizeApprovalStatusKey(status)}`; |
| | | } |
| | | |
| | | /** |
| | | * ä¸ Web buildApprovalInstanceListParams ä¸è´ï¼æå¹³ queryï¼current/size/businessType/...ï¼ |
| | | * 审æ¹å表ä¸ä¼ businessType 峿¥å
¨é¨ |
| | | */ |
| | | export function buildInstanceListParams({ |
| | | page, |
| | | businessType, |
| | | extraDto = {}, |
| | | searchForm, |
| | | }) { |
| | | const extra = { ...(extraDto && typeof extraDto === "object" ? extraDto : {}) }; |
| | | if (extra.createTime != null && extra.createTimeStart == null) { |
| | | extra.createTimeStart = extra.createTime; |
| | | } |
| | | delete extra.createTime; |
| | | |
| | | const params = { |
| | | current: page.current, |
| | | size: page.size, |
| | | ...extra, |
| | | }; |
| | | |
| | | const bizType = businessType ?? searchForm?.businessType; |
| | | if (bizType != null && bizType !== "") { |
| | | params.businessType = bizType; |
| | | } |
| | | if (searchForm?.status) { |
| | | params.status = searchForm.status; |
| | | } |
| | | |
| | | const range = |
| | | searchForm?.createTimeRange ?? |
| | | searchForm?.applyDateRange ?? |
| | | searchForm?.transferDateRange; |
| | | if (Array.isArray(range) && range[0] && params.createTimeStart == null) { |
| | | params.createTimeStart = range[0]; |
| | | } |
| | | if (Array.isArray(range) && range[1] && params.createTimeEnd == null) { |
| | | params.createTimeEnd = range[1]; |
| | | } |
| | | |
| | | return params; |
| | | } |
| | | |
| | | export function unwrapInstancePage(res) { |
| | | const data = res?.data ?? res; |
| | | return { |
| | | records: Array.isArray(data?.records) ? data.records : [], |
| | | total: Number(data?.total ?? 0), |
| | | }; |
| | | } |
| | | |
| | | /** ä»å®ä¾è¡æåå表å±ç¤ºå段ï¼label + valueï¼å« formPayloadï¼ */ |
| | | export function buildFormDisplayRows(row, listFields = []) { |
| | | const { fields, formPayload } = resolveInstanceFormPayload(row); |
| | | const fieldByKey = new Map((fields || []).map(f => [f.key, f])); |
| | | const rows = []; |
| | | const defs = listFields || []; |
| | | |
| | | if (defs.length) { |
| | | defs.forEach(def => { |
| | | if (!def?.prop) return; |
| | | const prop = def.prop; |
| | | const hit = fieldByKey.get(prop); |
| | | const raw = resolveListFieldRawValue(prop, row, fields, formPayload); |
| | | rows.push({ |
| | | label: def.label || hit?.label || prop, |
| | | value: formatListFieldDisplay(prop, raw, hit), |
| | | }); |
| | | }); |
| | | } else { |
| | | fields.slice(0, 3).forEach(f => { |
| | | rows.push({ label: f.label, value: displayFieldValue(f) }); |
| | | }); |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | /** å表è¡å¢å¼ºï¼ä¿çåå§å段ä¾è¯¦æ
/ç¼è¾ï¼ */ |
| | | export function mapInstanceListRow(row, listFields = []) { |
| | | if (!row) return {}; |
| | | const displayRows = buildFormDisplayRows(row, listFields); |
| | | const extra = {}; |
| | | const { fields, formPayload } = resolveInstanceFormPayload(row); |
| | | (listFields || []).forEach(def => { |
| | | if (!def?.prop) return; |
| | | const hit = fields.find(f => f.key === def.prop); |
| | | const raw = resolveListFieldRawValue(def.prop, row, fields, formPayload); |
| | | extra[def.prop] = formatListFieldDisplay(def.prop, raw, hit); |
| | | }); |
| | | return { |
| | | ...row, |
| | | approvalStatus: normalizeApprovalStatusKey(row.status), |
| | | summary: row.title || row.templateName || "", |
| | | createTime: formatDateTime(row.applyTime || row.createTime), |
| | | displayRows, |
| | | formPayload, |
| | | formFieldDefs: fields, |
| | | ...extra, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | import { |
| | | deleteFinReimbursement, |
| | | getFinReimbursementDetail, |
| | | persistFinReimbursement, |
| | | } from "@/api/oa/finReimbursement.js"; |
| | | import { APPROVAL_MODULE_KEYS } from "./approvalModuleRegistry.js"; |
| | | import { businessStatusClass, normalizeApprovalStatusKey } from "./approveListUtils.js"; |
| | | import { |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | expenseTypeToCategory, |
| | | } from "../ReimburseManage/_utils/costReimburseUtils.js"; |
| | | import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/travelReimburseUtils.js"; |
| | | import { EXPENSE_SUBJECT_OPTIONS as COST_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/costReimburseUtils.js"; |
| | | import { resolveExpenseSubjectLabel } from "../ReimburseManage/_utils/expenseDetailDisplay.js"; |
| | | import { applyFinReimbursementDetailEnrichment } from "../ReimburseManage/_utils/finReimbursementDetailExtras.js"; |
| | | |
| | | export const FIN_REIMBURSEMENT_TYPE = { |
| | | TRAVEL: "1", |
| | | COST: "2", |
| | | }; |
| | | |
| | | const REIMBURSEMENT_TYPE_LABEL = { |
| | | [FIN_REIMBURSEMENT_TYPE.TRAVEL]: "å·®æ
æ¥é", |
| | | [FIN_REIMBURSEMENT_TYPE.COST]: "è´¹ç¨æ¥é", |
| | | }; |
| | | |
| | | /** å½ä¸åæ¥éç±»åï¼1-å·®æ
ï¼2-è´¹ç¨ */ |
| | | export function normalizeReimbursementType(val) { |
| | | const s = String(val ?? "").trim(); |
| | | if (s === "1" || s === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | return FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | } |
| | | if (s === "2" || s === FIN_REIMBURSEMENT_TYPE.COST) { |
| | | return FIN_REIMBURSEMENT_TYPE.COST; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | export function reimbursementTypeLabel(type) { |
| | | return REIMBURSEMENT_TYPE_LABEL[normalizeReimbursementType(type)] || "â"; |
| | | } |
| | | |
| | | export function getModuleKeyByReimbursementType(type) { |
| | | const t = normalizeReimbursementType(type); |
| | | if (t === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | return APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE; |
| | | } |
| | | if (t === FIN_REIMBURSEMENT_TYPE.COST) { |
| | | return APPROVAL_MODULE_KEYS.COST_REIMBURSE; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** ä¼å
æ¥å£ reimbursementTypeï¼å
¶æ¬¡é¡µé¢ moduleKey / å
¥å */ |
| | | export function resolveReimbursementType(raw, fallback) { |
| | | const fromApi = normalizeReimbursementType(raw?.reimbursementType); |
| | | if (fromApi) return fromApi; |
| | | return ( |
| | | normalizeReimbursementType(fallback) || |
| | | getReimbursementTypeByModuleKey(fallback) || |
| | | "" |
| | | ); |
| | | } |
| | | |
| | | export function isTravelReimbursementType(type) { |
| | | return resolveReimbursementType({ reimbursementType: type }, type) === FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | } |
| | | |
| | | export function filterRowsByReimbursementType(rows, expectedType) { |
| | | const expected = normalizeReimbursementType(expectedType); |
| | | if (!expected) return rows || []; |
| | | return (rows || []).filter(row => { |
| | | const t = resolveReimbursementType(row, expected); |
| | | return t === expected; |
| | | }); |
| | | } |
| | | |
| | | const BILL_STATUS_LABEL = { |
| | | DRAFT: "è稿", |
| | | IN_APPROVAL: "审æ¹ä¸", |
| | | APPROVED: "审æ¹éè¿", |
| | | REJECTED: "审æ¹é©³å", |
| | | WITHDRAWN: "å·²æ¤å", |
| | | PAID: "已仿¬¾", |
| | | }; |
| | | |
| | | export function getReimbursementTypeByModuleKey(moduleKey) { |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE) { |
| | | return FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE) { |
| | | return FIN_REIMBURSEMENT_TYPE.COST; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | export function unwrapFinReimbursementPage(res) { |
| | | const data = res?.data ?? res; |
| | | if (!data || typeof data !== "object") { |
| | | return { records: [], total: 0 }; |
| | | } |
| | | if (Array.isArray(data.records)) { |
| | | return { records: data.records, total: Number(data.total ?? 0) }; |
| | | } |
| | | const nested = data.data; |
| | | if (nested && typeof nested === "object" && Array.isArray(nested.records)) { |
| | | return { records: nested.records, total: Number(nested.total ?? 0) }; |
| | | } |
| | | return { records: [], total: 0 }; |
| | | } |
| | | |
| | | /** 详æ
æ¥å£ data è§£å
*/ |
| | | export function unwrapFinReimbursementDetail(res) { |
| | | const data = res?.data ?? res; |
| | | if (!data || typeof data !== "object") return {}; |
| | | if (data.billNo != null || data.id != null || data.reimbursementType != null) { |
| | | return data; |
| | | } |
| | | const nested = data.data; |
| | | if (nested && typeof nested === "object" && !Array.isArray(nested)) { |
| | | return nested; |
| | | } |
| | | if (data.finReimbursementDto && typeof data.finReimbursementDto === "object") { |
| | | return data.finReimbursementDto; |
| | | } |
| | | return data; |
| | | } |
| | | |
| | | export function mapBillStatusToApprovalKey(billStatus) { |
| | | const upper = String(billStatus ?? "").trim().toUpperCase(); |
| | | if (upper === "DRAFT") return "draft"; |
| | | if (upper === "IN_APPROVAL") return "pending"; |
| | | if (upper === "APPROVED") return "approved"; |
| | | if (upper === "REJECTED") return "rejected"; |
| | | if (upper === "WITHDRAWN") return "cancelled"; |
| | | if (upper === "PAID") return "approved"; |
| | | return normalizeApprovalStatusKey(billStatus); |
| | | } |
| | | |
| | | export function billStatusLabel(billStatus) { |
| | | const upper = String(billStatus ?? "").trim().toUpperCase(); |
| | | if (BILL_STATUS_LABEL[upper]) return BILL_STATUS_LABEL[upper]; |
| | | const key = mapBillStatusToApprovalKey(billStatus); |
| | | if (key === "draft") return "è稿"; |
| | | if (key === "approved") return "已宿"; |
| | | if (key === "rejected") return "已驳å"; |
| | | if (key === "cancelled") return "å·²æ¤å"; |
| | | return "è¿è¡ä¸"; |
| | | } |
| | | |
| | | export function billStatusCssClass(item) { |
| | | return businessStatusClass( |
| | | mapBillStatusToApprovalKey(item?.billStatus ?? item?.status) |
| | | ); |
| | | } |
| | | |
| | | function pickApplicantQuery(searchForm = {}) { |
| | | const kw = (searchForm.applicantKeyword || "").trim(); |
| | | if (!kw) return {}; |
| | | if (/[\u4e00-\u9fa5]/.test(kw)) return { applicantName: kw }; |
| | | return { applicantCode: kw }; |
| | | } |
| | | |
| | | export function buildFinReimbursementListParams({ |
| | | page, |
| | | searchForm, |
| | | reimbursementType, |
| | | extraDto = {}, |
| | | }) { |
| | | const dto = { |
| | | reimbursementType, |
| | | ...pickApplicantQuery(searchForm), |
| | | ...(extraDto && typeof extraDto === "object" ? extraDto : {}), |
| | | }; |
| | | |
| | | const range = searchForm?.createTimeRange ?? searchForm?.applyDateRange; |
| | | if (Array.isArray(range) && range[0]) { |
| | | dto.createTimeStart = range[0]; |
| | | } |
| | | if (Array.isArray(range) && range[1]) { |
| | | dto.createTimeEnd = range[1]; |
| | | } |
| | | |
| | | if (reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | if (searchForm?.travelStartFrom) { |
| | | dto.startTimeStart = searchForm.travelStartFrom; |
| | | } |
| | | if (searchForm?.travelEndTo) { |
| | | dto.endTimeEnd = searchForm.travelEndTo; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | page: { |
| | | current: page.current, |
| | | size: page.size, |
| | | }, |
| | | finReimbursementDto: dto, |
| | | }; |
| | | } |
| | | |
| | | function pickTravelField(obj, keys) { |
| | | if (!obj || typeof obj !== "object") return ""; |
| | | for (const key of keys) { |
| | | const v = obj[key]; |
| | | if (v != null && v !== "") return v; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** å
¼å®¹ list/detail å¤ç§å·®æ
åå¯¹è±¡ç»æ */ |
| | | export function pickTravelFromRow(row) { |
| | | if (!row || typeof row !== "object") return {}; |
| | | const nested = |
| | | (row.travel && typeof row.travel === "object" ? row.travel : null) || |
| | | row.finReimbursementTravel || |
| | | row.finReimbursementTravelDto || |
| | | row.travelDto || |
| | | row.travelVO || |
| | | {}; |
| | | const src = |
| | | nested && typeof nested === "object" && Object.keys(nested).length |
| | | ? nested |
| | | : row; |
| | | return { |
| | | startTime: pickTravelField(src, [ |
| | | "startTime", |
| | | "travelStartTime", |
| | | "startDate", |
| | | "travelStartDate", |
| | | "departureTime", |
| | | ]), |
| | | endTime: pickTravelField(src, [ |
| | | "endTime", |
| | | "travelEndTime", |
| | | "endDate", |
| | | "travelEndDate", |
| | | "returnTime", |
| | | ]), |
| | | travelDays: src.travelDays, |
| | | departureCity: pickTravelField(src, [ |
| | | "departureCity", |
| | | "departurePlace", |
| | | "departure", |
| | | ]), |
| | | destinationCity: pickTravelField(src, [ |
| | | "destinationCity", |
| | | "destination", |
| | | "destinationPlace", |
| | | ]), |
| | | hotelStandard: src.hotelStandard, |
| | | lodgingDays: src.lodgingDays ?? src.hotelDays, |
| | | mealAllowance: src.mealAllowance ?? src.livingSubsidy, |
| | | transportAllowance: src.transportAllowance ?? src.transportSubsidy, |
| | | lodgingLimit: src.lodgingLimit, |
| | | withinStandard: src.withinStandard, |
| | | standardTag: src.standardTag || "", |
| | | id: src.id, |
| | | reimbursementId: src.reimbursementId, |
| | | }; |
| | | } |
| | | |
| | | export function formatReimbursementDateTime(val) { |
| | | if (val == null || val === "") return ""; |
| | | const d = dayjs(val); |
| | | if (!d.isValid()) return String(val); |
| | | const raw = String(val); |
| | | const hasTime = raw.includes("T") || /:\d{2}/.test(raw); |
| | | return hasTime ? d.format("YYYY-MM-DD HH:mm:ss") : d.format("YYYY-MM-DD"); |
| | | } |
| | | |
| | | export function mapFinReimbursementFromApi(row, { reimbursementType, moduleKey } = {}) { |
| | | if (!row) return {}; |
| | | const type = resolveReimbursementType( |
| | | row, |
| | | reimbursementType || getReimbursementTypeByModuleKey(moduleKey) |
| | | ); |
| | | const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | const travel = isTravel ? pickTravelFromRow(row) : {}; |
| | | const instanceId = row.approvalInstanceId ?? row.id; |
| | | |
| | | return { |
| | | ...row, |
| | | reimbursementId: row.id, |
| | | id: instanceId, |
| | | approvalInstanceId: row.approvalInstanceId, |
| | | instanceNo: row.billNo || "", |
| | | billNo: row.billNo || "", |
| | | reimbursementType: type, |
| | | reimbursementTypeLabel: reimbursementTypeLabel(type), |
| | | moduleKey: getModuleKeyByReimbursementType(type), |
| | | applicantNo: row.applicantCode || "", |
| | | applicantCode: row.applicantCode || "", |
| | | applicantName: row.applicantName || "", |
| | | reason: row.reason || "", |
| | | expenseType: row.expenseType || "", |
| | | applyAmount: row.applyAmount, |
| | | billStatus: row.billStatus, |
| | | status: row.billStatus, |
| | | approvalStatus: mapBillStatusToApprovalKey(row.billStatus), |
| | | title: row.reason || row.billNo || "", |
| | | summary: row.reason || row.billNo || "", |
| | | createTime: formatReimbursementDateTime(row.createTime), |
| | | departurePlace: travel.departureCity || "", |
| | | destination: travel.destinationCity || "", |
| | | travelStartTime: formatReimbursementDateTime(travel.startTime), |
| | | travelEndTime: formatReimbursementDateTime(travel.endTime), |
| | | travel, |
| | | details: row.details || [], |
| | | nodes: row.nodes || [], |
| | | flowNodes: row.nodes || [], |
| | | displayRows: buildFinReimbursementDisplayRows( |
| | | { |
| | | billNo: row.billNo, |
| | | applyAmount: row.applyAmount, |
| | | billStatus: row.billStatus, |
| | | departurePlace: travel.departureCity, |
| | | destination: travel.destinationCity, |
| | | expenseType: row.expenseType, |
| | | reason: row.reason, |
| | | }, |
| | | type |
| | | ), |
| | | }; |
| | | } |
| | | |
| | | export function buildFinReimbursementDisplayRows(item, reimbursementType) { |
| | | const type = normalizeReimbursementType(reimbursementType); |
| | | const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | const rows = [ |
| | | { label: "æ¥éåå·", value: item.billNo }, |
| | | { |
| | | label: "ç³è¯·éé¢", |
| | | value: item.applyAmount != null ? `${item.applyAmount} å
` : "", |
| | | }, |
| | | { label: "åæ®ç¶æ", value: billStatusLabel(item.billStatus) }, |
| | | ]; |
| | | if (isTravel) { |
| | | rows.splice( |
| | | 1, |
| | | 0, |
| | | { label: "åºå·®å°", value: item.departurePlace }, |
| | | { label: "ç®çå°", value: item.destination } |
| | | ); |
| | | } else { |
| | | rows.splice(1, 0, { label: "è´¹ç¨ç±»å", value: item.expenseType }); |
| | | } |
| | | if (item.reason) { |
| | | rows.push({ label: "æ¥éåå ", value: item.reason }); |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | /** ä¿®æ¹åºæ¯å¿
é¡»å¸¦ä¸»é® IDï¼ä¸ Web ä¸è´ï¼ */ |
| | | export function validateReimbursementPersistDto(dto, isEdit) { |
| | | if (!isEdit) return { ok: true }; |
| | | if (dto?.id != null && dto.id !== "") return { ok: true }; |
| | | return { ok: false, message: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID" }; |
| | | } |
| | | |
| | | export { deleteFinReimbursement, getFinReimbursementDetail, persistFinReimbursement }; |
| | | |
| | | /** å表è¡ä¸»é®ï¼å é¤/ä¿®æ¹ç¨ fin_reimbursement.idï¼å¿ç¨ item.id 审æ¹å®ä¾ IDï¼ */ |
| | | export function resolveReimbursementDeleteId(row) { |
| | | const raw = row?.reimbursementId; |
| | | if (raw == null || raw === "" || String(raw).startsWith("local_")) { |
| | | return undefined; |
| | | } |
| | | const n = Number(raw); |
| | | return Number.isNaN(n) ? raw : n; |
| | | } |
| | | |
| | | /** æ¯å¦å
许å é¤ï¼å®¡æ¹ä¸ãå·²éè¿ã已仿¬¾ä¸å¯å ï¼ */ |
| | | export function canDeleteReimbursementRow(row) { |
| | | const upper = String(row?.billStatus ?? row?.status ?? "").trim().toUpperCase(); |
| | | if (upper === "PAID") return false; |
| | | const key = mapBillStatusToApprovalKey( |
| | | row?.billStatus ?? row?.approvalStatus ?? row?.status |
| | | ); |
| | | return key !== "pending" && key !== "approved"; |
| | | } |
| | | |
| | | export function canEditReimbursementRow(row) { |
| | | return canDeleteReimbursementRow(row); |
| | | } |
| | | |
| | | /** æåæ¥é详æ
ï¼å«æç»ã审æ¹èç¹ï¼ä¸ Web mapFinReimbursementDetailRow ä¸è´ï¼ */ |
| | | export async function fetchFinReimbursementListItemDetail(item, reimbursementTypeOrModuleKey) { |
| | | const id = resolveReimbursementDeleteId(item); |
| | | if (id == null) { |
| | | throw new Error("missing reimbursement id"); |
| | | } |
| | | const res = await getFinReimbursementDetail(id); |
| | | const raw = unwrapFinReimbursementDetail(res); |
| | | const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey); |
| | | const row = mapFinReimbursementDetailRow(raw, type); |
| | | return { |
| | | ...row, |
| | | reimbursementType: type, |
| | | reimbursementTypeLabel: reimbursementTypeLabel(type), |
| | | moduleKey: getModuleKeyByReimbursementType(type), |
| | | displayRows: buildFinReimbursementDisplayRows( |
| | | { |
| | | billNo: row.billNo || row.reimburseNo, |
| | | applyAmount: row.applyAmount, |
| | | billStatus: row.billStatus, |
| | | departurePlace: row.departurePlace, |
| | | destination: row.destination, |
| | | expenseType: row.expenseCategory || row.expenseType, |
| | | reason: row.reimburseReason || row.reason, |
| | | }, |
| | | type |
| | | ), |
| | | }; |
| | | } |
| | | |
| | | function toNumber(val) { |
| | | if (val == null || val === "") return undefined; |
| | | const n = Number(val); |
| | | return Number.isNaN(n) ? undefined : n; |
| | | } |
| | | |
| | | function mapSignModeToApi(signMode) { |
| | | return signMode === "or_sign" ? "OR" : "AND"; |
| | | } |
| | | |
| | | function expenseSubjectToCategory(subject) { |
| | | const hit = |
| | | TRAVEL_EXPENSE_SUBJECTS.find(x => x.value === subject) || |
| | | COST_EXPENSE_SUBJECTS.find(x => x.value === subject); |
| | | return hit?.label || subject || ""; |
| | | } |
| | | |
| | | function mapDetailRowFromApi(d, reimbursementType) { |
| | | const type = normalizeReimbursementType(reimbursementType); |
| | | const raw = d.expenseCategory ?? d.expenseSubject ?? ""; |
| | | const opts = |
| | | type === FIN_REIMBURSEMENT_TYPE.TRAVEL |
| | | ? TRAVEL_EXPENSE_SUBJECTS |
| | | : COST_EXPENSE_SUBJECTS; |
| | | const label = resolveExpenseSubjectLabel(raw, { |
| | | isTravel: type === FIN_REIMBURSEMENT_TYPE.TRAVEL, |
| | | subjectOptions: opts, |
| | | }); |
| | | const hit = opts.find(x => x.value === raw || x.label === raw || x.label === label); |
| | | return { |
| | | ...d, |
| | | expenseSubject: hit?.value || raw, |
| | | }; |
| | | } |
| | | |
| | | function expenseCategoryToType(category) { |
| | | const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === category); |
| | | return hit?.label || category || ""; |
| | | } |
| | | |
| | | /** æ¥å£ nodes â 页é¢å®¡æ¹æµ */ |
| | | export function mapNodesToFormFlow(nodes = []) { |
| | | return (Array.isArray(nodes) ? nodes : []).map((n, i) => { |
| | | const first = Array.isArray(n.approvers) ? n.approvers[0] : null; |
| | | return { |
| | | ...n, |
| | | nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1, |
| | | signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign", |
| | | approverId: first?.approverId ?? n.approverId ?? "", |
| | | approverName: first?.approverName ?? n.approverName ?? "", |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | /** 页é¢å®¡æ¹èç¹ â æ¥å£ nodes */ |
| | | export function mapApprovalFlowNodesToApi(nodes = [], templateId) { |
| | | const list = Array.isArray(nodes) ? nodes : []; |
| | | return list |
| | | .map((n, i) => { |
| | | let approvers = []; |
| | | if (Array.isArray(n.approvers) && n.approvers.length) { |
| | | approvers = n.approvers |
| | | .filter(a => a?.approverId != null && a.approverId !== "") |
| | | .map((a, idx) => ({ |
| | | id: a.id, |
| | | nodeId: a.nodeId, |
| | | templateId: a.templateId ?? templateId, |
| | | approverId: toNumber(a.approverId) ?? a.approverId, |
| | | approverName: a.approverName || "", |
| | | sortNo: a.sortNo ?? idx + 1, |
| | | })); |
| | | } else if (n.approverId != null && n.approverId !== "") { |
| | | approvers = [ |
| | | { |
| | | approverId: toNumber(n.approverId) ?? n.approverId, |
| | | approverName: n.approverName || "", |
| | | sortNo: 1, |
| | | }, |
| | | ]; |
| | | } |
| | | if (!approvers.length) return null; |
| | | const node = { |
| | | levelNo: n.levelNo ?? n.nodeOrder ?? i + 1, |
| | | approveType: n.approveType || mapSignModeToApi(n.signMode), |
| | | approvers, |
| | | }; |
| | | if (n.id != null) node.id = n.id; |
| | | if (n.templateId != null) node.templateId = n.templateId; |
| | | else if (templateId != null) node.templateId = templateId; |
| | | return node; |
| | | }) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | function mapDetailsToApi(details = []) { |
| | | return (details || []).map((d, i) => { |
| | | const item = { |
| | | rowNo: d.rowNo ?? i + 1, |
| | | invoiceDate: d.invoiceDate || undefined, |
| | | expenseCategory: expenseSubjectToCategory(d.expenseSubject ?? d.expenseCategory), |
| | | amount: toNumber(d.amount), |
| | | description: d.description || "", |
| | | invoiceNo: d.invoiceNo || undefined, |
| | | invoiceType: d.invoiceType || undefined, |
| | | invoiceAmount: toNumber(d.invoiceAmount), |
| | | taxRate: toNumber(d.taxRate), |
| | | taxAmount: toNumber(d.taxAmount), |
| | | remark: d.remark || undefined, |
| | | }; |
| | | if (d.id != null && !String(d.id).startsWith("ed_")) { |
| | | item.id = toNumber(d.id) ?? d.id; |
| | | } |
| | | if (d.reimbursementId != null) item.reimbursementId = toNumber(d.reimbursementId); |
| | | return item; |
| | | }); |
| | | } |
| | | |
| | | function sumDetailAmount(details = []) { |
| | | const sum = (details || []).reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | return Math.round(sum * 100) / 100; |
| | | } |
| | | |
| | | function applyReimbursementRelations(dto) { |
| | | const rid = dto?.id; |
| | | if (rid == null) return dto; |
| | | if (dto.travel && typeof dto.travel === "object") { |
| | | dto.travel.reimbursementId = rid; |
| | | } |
| | | if (Array.isArray(dto.details)) { |
| | | dto.details.forEach(d => { |
| | | d.reimbursementId = rid; |
| | | }); |
| | | } |
| | | return dto; |
| | | } |
| | | |
| | | function resolveReimbursementId(form) { |
| | | const rawId = form?.reimbursementId ?? form?.id; |
| | | if (rawId == null || rawId === "" || String(rawId).startsWith("local_")) { |
| | | return undefined; |
| | | } |
| | | return toNumber(rawId) ?? rawId; |
| | | } |
| | | |
| | | /** æ¥å£è¡ â å·®æ
æ¥é表åè¡ */ |
| | | export function mapTravelReimbursementRow(row) { |
| | | if (!row) return {}; |
| | | const travel = pickTravelFromRow(row); |
| | | const details = Array.isArray(row.details) ? row.details : []; |
| | | return { |
| | | ...row, |
| | | id: row.id, |
| | | reimbursementId: row.id, |
| | | approvalInstanceId: row.approvalInstanceId, |
| | | reimburseNo: row.billNo || "", |
| | | applicantId: row.applicantId, |
| | | applicantNo: row.applicantCode || "", |
| | | applicantName: row.applicantName || "", |
| | | employeeNo: row.applicantCode || "", |
| | | employeeName: row.applicantName || "", |
| | | applicantDeptName: row.applicantDeptName || "", |
| | | reimburseReason: row.reason || "", |
| | | travelStartTime: formatReimbursementDateTime(travel.startTime), |
| | | travelEndTime: formatReimbursementDateTime(travel.endTime), |
| | | travelDays: travel.travelDays, |
| | | departurePlace: travel.departureCity || "", |
| | | destination: travel.destinationCity || "", |
| | | hotelStandard: travel.hotelStandard, |
| | | hotelDays: travel.lodgingDays, |
| | | livingSubsidy: travel.mealAllowance, |
| | | transportSubsidy: travel.transportAllowance, |
| | | lodgingLimit: travel.lodgingLimit, |
| | | needSpecialApproval: travel.withinStandard === "0" || travel.withinStandard === 0, |
| | | standardTag: travel.standardTag || "", |
| | | applyAmount: row.applyAmount, |
| | | payee: row.payeeName || "", |
| | | payeeAccount: row.payeeAccount || "", |
| | | payeeBank: row.payeeBank || "", |
| | | billStatus: row.billStatus, |
| | | expenseDetails: details.map(d => |
| | | mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.TRAVEL) |
| | | ), |
| | | travel: |
| | | row.travel && typeof row.travel === "object" && Object.keys(row.travel).length |
| | | ? row.travel |
| | | : travel, |
| | | details, |
| | | nodes: row.nodes || [], |
| | | approvalFlowNodes: mapNodesToFormFlow(row.nodes), |
| | | tasks: row.tasks || [], |
| | | attachmentList: row.attachmentList || row.invoiceAttachments || [], |
| | | }; |
| | | } |
| | | |
| | | /** æ¥å£è¡ â è´¹ç¨æ¥é表åè¡ */ |
| | | export function mapCostReimbursementRow(row) { |
| | | if (!row) return {}; |
| | | const details = Array.isArray(row.details) ? row.details : []; |
| | | return { |
| | | ...row, |
| | | id: row.id, |
| | | reimbursementId: row.id, |
| | | approvalInstanceId: row.approvalInstanceId, |
| | | reimburseNo: row.billNo || "", |
| | | applicantId: row.applicantId, |
| | | applicantNo: row.applicantCode || "", |
| | | applicantName: row.applicantName || "", |
| | | employeeNo: row.applicantCode || "", |
| | | employeeName: row.applicantName || "", |
| | | applicantDeptName: row.applicantDeptName || "", |
| | | reimburseReason: row.reason || "", |
| | | expenseCategory: expenseTypeToCategory(row.expenseType), |
| | | applyAmount: row.applyAmount, |
| | | applyTime: formatReimbursementDateTime(row.createTime), |
| | | createTime: formatReimbursementDateTime(row.createTime), |
| | | payee: row.payeeName || "", |
| | | payeeAccount: row.payeeAccount || "", |
| | | bankBranch: row.payeeBank || "", |
| | | payeeBank: row.payeeBank || "", |
| | | billStatus: row.billStatus, |
| | | expenseDetails: details.map(d => |
| | | mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.COST) |
| | | ), |
| | | details, |
| | | nodes: row.nodes || [], |
| | | approvalFlowNodes: mapNodesToFormFlow(row.nodes), |
| | | tasks: row.tasks || [], |
| | | attachmentList: row.attachmentList || row.invoiceAttachments || [], |
| | | }; |
| | | } |
| | | |
| | | export function mapFinReimbursementDetailRow(raw, reimbursementTypeOrModuleKey) { |
| | | const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey); |
| | | let mapped = {}; |
| | | if (type === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | mapped = mapTravelReimbursementRow(raw); |
| | | } else if (type === FIN_REIMBURSEMENT_TYPE.COST) { |
| | | mapped = mapCostReimbursementRow(raw); |
| | | } else { |
| | | mapped = raw || {}; |
| | | } |
| | | return { |
| | | ...applyFinReimbursementDetailEnrichment(mapped, raw), |
| | | reimbursementType: type, |
| | | reimbursementTypeLabel: reimbursementTypeLabel(type), |
| | | moduleKey: getModuleKeyByReimbursementType(type), |
| | | }; |
| | | } |
| | | |
| | | /** å·®æ
表å â FinReimbursementDto */ |
| | | export function buildTravelReimbursementSaveDto(form, { computeTravelDays } = {}) { |
| | | const details = mapDetailsToApi(form.expenseDetails); |
| | | const detailTotal = sumDetailAmount(form.expenseDetails); |
| | | const applyAmount = toNumber(form.applyAmount) ?? detailTotal; |
| | | const travelDays = |
| | | form.travelDays != null |
| | | ? toNumber(form.travelDays) |
| | | : computeTravelDays?.(form.travelStartTime, form.travelEndTime); |
| | | |
| | | const dto = { |
| | | reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL, |
| | | expenseType: "å·®æ
è´¹", |
| | | applicantId: toNumber(form.applicantId), |
| | | applicantCode: form.employeeNo || form.applicantNo || "", |
| | | applicantName: form.employeeName || form.applicantName || "", |
| | | applicantDeptId: toNumber(form.applicantDeptId), |
| | | applicantDeptName: form.applicantDeptName || form.deptName || "", |
| | | reason: (form.reimburseReason || "").trim(), |
| | | applyAmount, |
| | | detailTotalAmount: detailTotal, |
| | | payeeName: form.payee || "", |
| | | payeeAccount: form.payeeAccount || undefined, |
| | | payeeBank: form.payeeBank || undefined, |
| | | billStatus: "IN_APPROVAL", |
| | | deptId: toNumber(form.deptId), |
| | | travel: { |
| | | startTime: form.travelStartTime || undefined, |
| | | endTime: form.travelEndTime || undefined, |
| | | travelDays, |
| | | departureCity: form.departurePlace || "", |
| | | destinationCity: form.destination || "", |
| | | hotelStandard: toNumber(form.hotelStandard), |
| | | lodgingDays: toNumber(form.hotelDays), |
| | | mealAllowance: toNumber(form.livingSubsidy), |
| | | transportAllowance: toNumber(form.transportSubsidy), |
| | | lodgingLimit: toNumber(form.lodgingLimit), |
| | | standardTag: form.standardTag || (form.needSpecialApproval ? "è¶
æ ç¹æ¹" : "卿 åèå´å
"), |
| | | withinStandard: form.needSpecialApproval ? "0" : "1", |
| | | }, |
| | | details, |
| | | nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId), |
| | | }; |
| | | |
| | | const id = resolveReimbursementId(form); |
| | | if (id != null) dto.id = id; |
| | | if (form.billNo || form.reimburseNo) dto.billNo = form.billNo || form.reimburseNo; |
| | | if (form.approvalInstanceId != null) dto.approvalInstanceId = toNumber(form.approvalInstanceId); |
| | | if (form.approveProcessId != null) dto.approveProcessId = toNumber(form.approveProcessId); |
| | | if (form.travel?.id != null) dto.travel.id = toNumber(form.travel.id); |
| | | |
| | | return applyReimbursementRelations(dto); |
| | | } |
| | | |
| | | /** è´¹ç¨è¡¨å â FinReimbursementDto */ |
| | | export function buildCostReimbursementSaveDto(form) { |
| | | const details = mapDetailsToApi(form.expenseDetails); |
| | | const detailTotal = sumDetailAmount(form.expenseDetails); |
| | | const applyAmount = toNumber(form.applyAmount) ?? detailTotal; |
| | | |
| | | const dto = { |
| | | reimbursementType: FIN_REIMBURSEMENT_TYPE.COST, |
| | | expenseType: expenseCategoryToType(form.expenseCategory), |
| | | applicantId: toNumber(form.applicantId), |
| | | applicantCode: form.employeeNo || form.applicantNo || "", |
| | | applicantName: form.employeeName || form.applicantName || "", |
| | | applicantDeptId: toNumber(form.applicantDeptId), |
| | | applicantDeptName: form.applicantDeptName || form.deptName || "", |
| | | reason: (form.reimburseReason || "").trim(), |
| | | applyAmount, |
| | | detailTotalAmount: detailTotal, |
| | | payeeName: form.payee || "", |
| | | payeeAccount: form.payeeAccount || "", |
| | | payeeBank: form.bankBranch || form.payeeBank || "", |
| | | billStatus: "IN_APPROVAL", |
| | | deptId: toNumber(form.deptId), |
| | | details, |
| | | nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId), |
| | | }; |
| | | |
| | | const id = resolveReimbursementId(form); |
| | | if (id != null) dto.id = id; |
| | | if (form.billNo || form.reimburseNo) dto.billNo = form.billNo || form.reimburseNo; |
| | | if (form.approvalInstanceId != null) dto.approvalInstanceId = toNumber(form.approvalInstanceId); |
| | | if (form.approveProcessId != null) dto.approveProcessId = toNumber(form.approveProcessId); |
| | | |
| | | return applyReimbursementRelations(dto); |
| | | } |
| | | |
| | | /** å¡«æ¥é¡µå 载详æ
ï¼ä¸ Web openFormDialog edit ä¸è´ï¼ */ |
| | | export async function fetchFinReimbursementFormDetail(item, moduleKey) { |
| | | const id = resolveReimbursementDeleteId(item); |
| | | if (id == null) throw new Error("missing reimbursement id"); |
| | | const res = await getFinReimbursementDetail(id); |
| | | const raw = unwrapFinReimbursementDetail(res); |
| | | return mapFinReimbursementDetailRow(raw, moduleKey); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { matchBusinessTypeValue } from "./approvalTemplateType.js"; |
| | | import { |
| | | APPROVAL_MODULE_KEYS, |
| | | getApprovalModuleConfig, |
| | | } from "./approvalModuleRegistry.js"; |
| | | import { fetchFinReimbursementListItemDetail } from "./finReimbursementMappers.js"; |
| | | |
| | | export const REIMBURSE_EDIT_FROM_APPROVE_KEY = "oa_reimburse_edit_from_approve"; |
| | | export const FIN_REIMBURSE_FORM_ACTION_KEY = "oa_fin_reimburse_form_action"; |
| | | |
| | | const REIMBURSE_MODULE_KEYS = [ |
| | | APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE, |
| | | APPROVAL_MODULE_KEYS.COST_REIMBURSE, |
| | | ]; |
| | | |
| | | export function inferReimburseModuleKeyFromInstance(row) { |
| | | if (!row) return ""; |
| | | for (const moduleKey of REIMBURSE_MODULE_KEYS) { |
| | | const cfg = getApprovalModuleConfig(moduleKey); |
| | | if (!cfg) continue; |
| | | if ( |
| | | cfg.businessType != null && |
| | | cfg.businessType !== "" && |
| | | matchBusinessTypeValue(row.businessType, cfg.businessType) |
| | | ) { |
| | | return moduleKey; |
| | | } |
| | | if (matchBusinessTypeValue(row.businessType, cfg.approvalType)) { |
| | | return moduleKey; |
| | | } |
| | | const text = `${row.templateName || ""}${row.title || ""}${row.businessName || ""}`; |
| | | if ((cfg.typeLabels || []).some(l => l && text.includes(l))) { |
| | | return moduleKey; |
| | | } |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | export function isReimburseApprovalInstance(row) { |
| | | return Boolean(inferReimburseModuleKeyFromInstance(row)); |
| | | } |
| | | |
| | | export function resolveFinReimbursementIdFromInstance(row) { |
| | | const raw = row?.businessId ?? row?.formPayload?.reimbursementId; |
| | | if (raw == null || raw === "") return undefined; |
| | | const n = Number(raw); |
| | | return Number.isNaN(n) ? raw : n; |
| | | } |
| | | |
| | | export async function loadReimburseDetailForInstance(instanceRow, moduleKey) { |
| | | const mk = moduleKey || inferReimburseModuleKeyFromInstance(instanceRow); |
| | | const id = resolveFinReimbursementIdFromInstance(instanceRow); |
| | | if (id == null) { |
| | | throw new Error("missing reimbursement id"); |
| | | } |
| | | const reimburseRow = await fetchFinReimbursementListItemDetail( |
| | | { reimbursementId: id }, |
| | | mk |
| | | ); |
| | | return { |
| | | reimburseRow, |
| | | instanceRow, |
| | | moduleKey: reimburseRow.moduleKey || mk, |
| | | reimbursementType: reimburseRow.reimbursementType, |
| | | }; |
| | | } |
| | | |
| | | export function stashReimburseEditFromApprove(moduleKey, reimbursementId) { |
| | | uni.setStorageSync( |
| | | REIMBURSE_EDIT_FROM_APPROVE_KEY, |
| | | JSON.stringify({ moduleKey, reimbursementId }) |
| | | ); |
| | | } |
| | | |
| | | export function consumeReimburseEditFromApprove() { |
| | | const raw = uni.getStorageSync(REIMBURSE_EDIT_FROM_APPROVE_KEY); |
| | | if (!raw) return null; |
| | | uni.removeStorageSync(REIMBURSE_EDIT_FROM_APPROVE_KEY); |
| | | try { |
| | | return typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function stashFinReimburseFormAction(payload) { |
| | | uni.setStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY, JSON.stringify(payload)); |
| | | } |
| | | |
| | | export function consumeFinReimburseFormAction() { |
| | | const raw = uni.getStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY); |
| | | if (!raw) return null; |
| | | uni.removeStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY); |
| | | try { |
| | | return typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** ç¨æ·å表解å
*/ |
| | | export function unwrapUserList(res) { |
| | | if (Array.isArray(res)) return res; |
| | | if (Array.isArray(res?.data)) return res.data; |
| | | if (Array.isArray(res?.rows)) return res.rows; |
| | | return []; |
| | | } |
| | | |
| | | export function isActiveUser(u) { |
| | | if (u?.delFlag === "2" || u?.delFlag === 2) return false; |
| | | if (u?.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | export 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 ?? ""}`; |
| | | } |
| | | |
| | | export function userSubLabel(u) { |
| | | const parts = []; |
| | | const code = u?.userName || u?.userCode || ""; |
| | | if (code) parts.push(`å·¥å· ${code}`); |
| | | const dept = u?.dept?.deptName ?? u?.deptName ?? ""; |
| | | if (dept) parts.push(dept); |
| | | return parts.join(" · ") || ""; |
| | | } |
| | | |
| | | const AVATAR_COLORS = ["#409EFF", "#67C23A", "#E6A23C", "#9B59B6", "#1ABC9C", "#F56C6C"]; |
| | | |
| | | export function userAvatarColor(name) { |
| | | if (!name) return "#c0c4cc"; |
| | | let h = 0; |
| | | for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h); |
| | | return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]; |
| | | } |
| | | |
| | | /** æå§å/å·¥å·/ID æç´¢ï¼ç©ºå
³é®åæ¶ä¼å
å±ç¤ºå limit æ¡ */ |
| | | export function filterActiveUsers(list, keyword, limit = 80) { |
| | | const active = (list || []).filter(isActiveUser); |
| | | const q = (keyword || "").trim().toLowerCase(); |
| | | if (!q) return active.slice(0, limit); |
| | | return active |
| | | .filter(u => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const name = (u.userName || "").toLowerCase(); |
| | | const id = String(u.userId ?? u.id ?? ""); |
| | | return nick.includes(q) || name.includes(q) || id.includes(q); |
| | | }) |
| | | .slice(0, limit); |
| | | } |