| src/api/oa/approvalInstance.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/config/oaWorkbench.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-list/apply.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-list/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-template/detail.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-template/edit.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-template/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/_utils/approvalFormField.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/_utils/approvalTemplateType.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/api/oa/approvalInstance.js
@@ -18,7 +18,10 @@ }); } /** å®¡æ ¸ä¸ä¿®æ¹å®¡æ¹å®ä¾ PUT /approvalInstance/update */ /** * ä¿®æ¹å®¡æ¹å®ä¾ PUT /approvalInstance/update * @param {Object} approvalInstanceDto 审æ¹å®ä¾ï¼éå« idï¼å ¶ä½å段æä¸å¡ä¿ç/æ´æ°ï¼ */ export function updateApprovalInstance(approvalInstanceDto) { return request({ url: "/approvalInstance/update", src/config/oaWorkbench.js
@@ -8,13 +8,13 @@ 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/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 }, ], }, { @@ -33,14 +33,14 @@ { 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: "审æ¹ç®¡ç", @@ -49,20 +49,20 @@ { 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 }, // ], // }, ]; /** å·¥ä½å°æå¹³èåï¼çº¯å端é ç½®ï¼ */ src/pages/oa/ApproveManage/approve-list/apply.vue
@@ -16,83 +16,150 @@ <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="88" 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="88" input-align="right" class="dynamic-form"> <up-form-item v-for="field in formConfigData.fields" :key="field.key" :label="field.label" :required="!!field.required" :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> <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> @@ -119,10 +186,16 @@ @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> @@ -137,7 +210,28 @@ 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 { userListNoPageByTenantId } from "@/api/system/user"; 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"; const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å ", "ä¸", "å «", "ä¹", "å"]; @@ -154,12 +248,25 @@ 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 || "-" @@ -173,28 +280,72 @@ () => 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 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 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 => { @@ -202,23 +353,60 @@ }); 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 = () => { @@ -230,8 +418,35 @@ 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) { @@ -272,15 +487,23 @@ 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, }; }; @@ -296,7 +519,7 @@ submitApi(payload) .then(() => { uni.showToast({ title: isEditMode.value ? "ä¿åæå" : "æäº¤æå", title: isEditMode.value ? "ä¿®æ¹æå" : "æäº¤æå", icon: "success", }); if (isEditMode.value) { @@ -308,7 +531,7 @@ }) .catch(() => { uni.showToast({ title: isEditMode.value ? "ä¿å失败" : "æäº¤å¤±è´¥", title: isEditMode.value ? "ä¿®æ¹å¤±è´¥" : "æäº¤å¤±è´¥", icon: "none", }); }) @@ -352,9 +575,9 @@ 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; templateId.value = row.templateId; form.title = row.title || ""; @@ -363,6 +586,7 @@ detail.value = null; try { await loadTemplateDetail(); if (!detail.value) return; initFormValues(formConfigData.value.fields); } finally { loading.value = false; @@ -373,7 +597,25 @@ uni.navigateBack(); }; const loadPickerSourceData = () => { userListNoPageByTenantId() .then(res => { pickerUserList.value = res?.data || []; }) .catch(() => { pickerUserList.value = []; }); getDept() .then(res => { pickerDeptList.value = res?.data || []; }) .catch(() => { pickerDeptList.value = []; }); }; onLoad(options => { loadPickerSourceData(); if (options?.id) { instanceId.value = options.id; loadForEdit(); @@ -389,11 +631,22 @@ </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 { @@ -412,130 +665,347 @@ .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) { flex-direction: column !important; align-items: stretch !important; padding: 10px 0 12px !important; } :deep(.form-item-textarea .u-form-item__content) { width: 100% !important; justify-content: stretch !important; } .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; } :deep(.form-item-daterange .u-form-item__body) { flex-direction: column !important; align-items: stretch !important; } :deep(.form-item-daterange .u-form-item__content) { width: 100% !important; justify-content: stretch !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; src/pages/oa/ApproveManage/approve-list/index.vue
@@ -73,20 +73,22 @@ </view> <view class="detail-row"> <text class="detail-label">ç³è¯·æ¶é´</text> <text class="detail-value">{{ item.applyTime || "-" }}</text> <text class="detail-value">{{ formatDateTime(item.applyTime) }}</text> </view> <view v-if="item.finishTime" class="detail-row"> <text class="detail-label">宿æ¶é´</text> <text class="detail-value">{{ item.finishTime }}</text> <text class="detail-value">{{ formatDateTime(item.finishTime) }}</text> </view> </view> <view v-if="canEdit(item) || item.isApprove" <view v-if="canModify(item) || item.isApprove" class="action-buttons"> <up-button v-if="canEdit(item)" <up-button v-if="canModify(item)" class="action-btn" size="small" @click.stop="goEdit(item)"> type="warning" plain @click.stop="goModify(item)"> ç¼è¾ </up-button> <up-button v-if="item.isApprove" @@ -123,6 +125,7 @@ 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"; const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row"; const userStore = useUserStore(); @@ -160,6 +163,27 @@ if (level == null || level === "") return "-"; return `第 ${level} 级`; }; const formatDateTime = val => { if (!val) return "-"; return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val); }; /** æ¯å¦æ¬äººåèµ·ç审æ¹ï¼å ¼å®¹å表æªè¿å applicantIdï¼ */ const isOwnApplication = item => { 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; }; /** ä» ãè¿è¡ä¸ã䏿¬äººåèµ·æ¶å¯ç¼è¾ï¼å·²éè¿/已驳å䏿¾ç¤ºç¼è¾ï¼ */ const canModify = item => item?.status === "PENDING" && isOwnApplication(item); const currentApproverName = item => { const tasks = item?.tasks; @@ -243,11 +267,11 @@ uni.navigateTo({ url: OA_NAV.approveListTemplateSelect }); }; const canEdit = item => item?.status === "PENDING" && String(item.applicantId) === String(userStore.id); const goEdit = item => { const goModify = item => { if (!canModify(item)) { uni.showToast({ title: "ä» è¿è¡ä¸çæ¬äººç³è¯·å¯ç¼è¾", icon: "none" }); return; } if (!item?.id) return; uni.setStorageSync(EDIT_STORAGE_KEY, item); uni.navigateTo({ src/pages/oa/ApproveManage/approve-template/detail.vue
@@ -125,6 +125,7 @@ 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, @@ -133,13 +134,6 @@ 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); @@ -179,7 +173,7 @@ return ""; }; const fieldTypeLabel = type => FIELD_TYPE_MAP[type] || type || "-"; const fieldTypeLabel = type => getFieldEditorTypeLabel(type); const approveTypeText = type => (type === "OR" ? "æç¾" : "ä¼ç¾"); src/pages/oa/ApproveManage/approve-template/edit.vue
@@ -26,17 +26,21 @@ 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> @@ -62,12 +66,16 @@ <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"> @@ -83,34 +91,46 @@ 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 @@ -196,11 +216,11 @@ @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" @@ -208,71 +228,255 @@ @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" @@ -326,67 +530,65 @@ 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: "", @@ -403,9 +605,12 @@ const fieldDraft = reactive({ label: "", key: "", type: "text", defaultValue: "", required: true, optionSource: "manual", options: [createEmptyFieldOption()], }); let nodeKeySeed = 1; @@ -451,10 +656,57 @@ 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", @@ -465,22 +717,14 @@ 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) { @@ -515,9 +759,12 @@ 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); }; @@ -530,8 +777,14 @@ 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 = { @@ -539,6 +792,8 @@ 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"; }; @@ -548,6 +803,7 @@ }; const openBusinessTypeSheet = () => { if (isSystemTemplate.value) return; if (!businessTypeOptions.value.length) { uni.showToast({ title: "审æ¹ç±»åå è½½ä¸", icon: "none" }); return; @@ -561,51 +817,316 @@ 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 => { @@ -622,22 +1143,46 @@ }; 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 { @@ -647,6 +1192,11 @@ }; const removeField = index => { const field = formConfig.fields[index]; if (isFieldLocked(field)) { uni.showToast({ title: "ç³»ç»å 置填æ¥é¡¹ä¸å¯å é¤", icon: "none" }); return; } formConfig.fields.splice(index, 1); }; @@ -850,6 +1400,13 @@ .catch(() => { userList.value = []; }); getDept() .then(res => { deptList.value = res?.data || []; }) .catch(() => { deptList.value = []; }); }); </script> @@ -899,6 +1456,12 @@ } } .section-head-left { display: flex; flex-direction: column; gap: 4px; } .section-title { font-size: 15px; font-weight: 600; @@ -906,6 +1469,12 @@ 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 { @@ -918,10 +1487,34 @@ 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 { @@ -1141,11 +1734,36 @@ .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 { @@ -1156,14 +1774,40 @@ .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 { @@ -1189,6 +1833,11 @@ &--date { color: #18a058; background: #e8faf0; } &--select { color: #9c27b0; background: #f6edfc; } } @@ -1231,10 +1880,13 @@ } .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 { @@ -1410,65 +2062,433 @@ } .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 { @@ -1494,40 +2514,37 @@ } } .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; } } src/pages/oa/ApproveManage/approve-template/index.vue
@@ -80,7 +80,8 @@ @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 @@ -119,6 +120,7 @@ buildTypeLabelMap, fetchApprovalTemplateTypes, getTemplateTypeLabel, isSystemApprovalTemplate, } from "../../_utils/approvalTemplateType.js"; const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; @@ -163,6 +165,8 @@ const businessTypeText = type => getTemplateTypeLabel(type, typeLabelMap.value); const isSystemTemplate = isSystemApprovalTemplate; const loadTemplateTypes = () => fetchApprovalTemplateTypes() @@ -250,6 +254,10 @@ const handleDelete = item => { if (!item?.id) return; if (isSystemTemplate(item)) { uni.showToast({ title: "ç³»ç»å 置模æ¿ä¸å¯å é¤", icon: "none" }); return; } const name = item.templateName || "该模æ¿"; uni.showModal({ title: "å é¤ç¡®è®¤", src/pages/oa/_utils/approvalFormField.js
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,363 @@ 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 */ export function parseApprovalFormConfig(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: [] }; } } /** * ä¿®æ¹å®¡æ¹ï¼æ¨¡æ¿å段å®ä¹ + å®ä¾å·²å¡«å¼åå¹¶ */ 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; } }); 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; } src/pages/oa/_utils/approvalTemplateType.js
@@ -6,6 +6,13 @@ */ 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 },