| src/api/basic/enum.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/api/oa/approvalInstance.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/api/oa/approvalTemplate.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/config/oaPaths.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-list/apply.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-list/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/oa/ApproveManage/approve-list/template-select.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/approvalTemplateType.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/api/basic/enum.js
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,9 @@ import request from "@/utils/request"; /** å®¡æ¹æ¨¡æ¿ç±»åæä¸¾ GET /basic/enum/TypeEnums */ export function getTypeEnums() { return request({ url: "/basic/enum/TypeEnums", method: "get", }); } src/api/oa/approvalInstance.js
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,28 @@ import request from "@/utils/request"; /** 审æ¹å®ä¾å页æ¥è¯¢ GET /approvalInstance/listPage */ export function listApprovalInstancePage(params) { return request({ url: "/approvalInstance/listPage", method: "get", params, }); } /** æ°å»ºå®¡æ¹å®ä¾ POST /approvalInstance/save */ export function saveApprovalInstance(approvalInstanceDto) { return request({ url: "/approvalInstance/save", method: "post", data: { approvalInstanceDto }, }); } /** å®¡æ ¸ä¸ä¿®æ¹å®¡æ¹å®ä¾ PUT /approvalInstance/update */ export function updateApprovalInstance(approvalInstanceDto) { return request({ url: "/approvalInstance/update", method: "put", data: { approvalInstanceDto }, }); } src/api/oa/approvalTemplate.js
@@ -1,5 +1,16 @@ import request from "@/utils/request"; /** * æ templateType æ¥è¯¢å·²å¯ç¨æ¨¡æ¿å表ï¼é businessTypeï¼ * GET /approvalTemplate/list/{templateType} ä¾ï¼list/1 = èªå®ä¹å·²å¯ç¨ */ export function listApprovalTemplateByType(templateType) { return request({ url: `/approvalTemplate/list/${templateType}`, method: "get", }); } /** å®¡æ¹æ¨¡æ¿å页æ¥è¯¢ */ export function listApprovalTemplatePage(params) { return request({ src/config/oaPaths.js
@@ -24,6 +24,8 @@ 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`, approveTemplate: `/${P}/ApproveManage/approve-template/index`, approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`, approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`, src/pages.json
@@ -1404,6 +1404,20 @@ } }, { "path": "pages/oa/ApproveManage/approve-list/template-select", "style": { "navigationBarTitleText": "éæ©å®¡æ¹æ¨¡æ¿", "navigationStyle": "custom" } }, { "path": "pages/oa/ApproveManage/approve-list/apply", "style": { "navigationBarTitleText": "å起审æ¹", "navigationStyle": "custom" } }, { "path": "pages/oa/ApproveManage/approve-template/index", "style": { "navigationBarTitleText": "å®¡æ¹æ¨¡æ¿", src/pages/oa/ApproveManage/approve-list/apply.vue
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,548 @@ <!-- OA / 审æ¹ç®¡ç / åèµ·å®¡æ¹ è·¯ç±ï¼/pages/oa/ApproveManage/approve-list/apply --> <template> <view class="approve-apply-page"> <PageHeader :title="pageTitle" @back="goBack" /> <scroll-view class="form-scroll" scroll-y :show-scrollbar="false"> <view v-if="loading" class="loading-wrap"> <up-loading-icon mode="circle" /> <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-input v-model="form.title" 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> <view class="section"> <view class="section-title">å¡«æ¥å 容</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'" 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]" :placeholder="`è¯·éæ©${field.label}`" readonly /> </view> <up-input v-else v-model="formValues[field.key]" :type="field.type === 'number' ? 'digit' : 'text'" :placeholder="`请è¾å ¥${field.label}`" clearable /> </view> </view> <view v-else class="empty-hint">è¯¥æ¨¡æ¿ææ å¡«æ¥é¡¹</view> </view> <view class="section"> <view class="section-title">å®¡æ¹æµç¨</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> </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> </view> </view> <view v-else class="empty-hint">ææ å®¡æ¹èç¹</view> </view> </template> <view v-else class="empty-wrap"> <up-empty mode="data" text="æªè·åå°æ¨¡æ¿è¯¦æ " /> </view> </scroll-view> <FooterButtons v-if="!loading && detail" cancel-text="åæ¶" :confirm-text="confirmText" :loading="submitting" @cancel="goBack" @confirm="handleSubmit" /> <up-popup :show="showDatePicker" mode="bottom" @close="showDatePicker = false"> <up-datetime-picker :show="true" v-model="datePickerTs" mode="date" @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 FooterButtons from "@/components/FooterButtons.vue"; import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js"; import { saveApprovalInstance, updateApprovalInstance, } from "@/api/oa/approvalInstance.js"; import useUserStore from "@/store/modules/user"; import { formatDateToYMD, parseTime } from "@/utils/ruoyi"; const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row"; const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å ", "ä¸", "å «", "ä¹", "å"]; const userStore = useUserStore(); const templateId = ref(""); const instanceId = ref(""); const instanceRow = ref(null); const detail = ref(null); const loading = ref(false); const submitting = ref(false); const formValues = reactive({}); const form = reactive({ title: "" }); const showDatePicker = ref(false); const datePickerTs = ref(Date.now()); const activeDateFieldKey = ref(""); const isEditMode = computed(() => !!instanceId.value); const pageTitle = computed(() => (isEditMode.value ? "ç¼è¾å®¡æ¹" : "å起审æ¹")); const confirmText = computed(() => (isEditMode.value ? "ä¿å" : "æäº¤å®¡æ¹")); const applicantName = computed( () => userStore.nickName || userStore.name || "-" ); const displayApplicantName = computed( () => instanceRow.value?.applicantName || applicantName.value ); const templateName = computed( () => 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); }); const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n); const approveTypeText = type => (type === "OR" ? "æç¾" : "ä¼ç¾"); const initFormValues = fields => { Object.keys(formValues).forEach(key => { delete formValues[key]; }); fields.forEach(field => { if (!field?.key) return; formValues[field.key] = field.value ?? field.defaultValue ?? ""; }); }; const openDatePicker = fieldKey => { activeDateFieldKey.value = fieldKey; const current = formValues[fieldKey]; datePickerTs.value = current ? new Date(current).getTime() : Date.now(); showDatePicker.value = true; }; const onDateConfirm = e => { const ts = e?.value ?? datePickerTs.value; if (activeDateFieldKey.value) { formValues[activeDateFieldKey.value] = formatDateToYMD(ts); } showDatePicker.value = false; }; const validateForm = () => { if (!form.title?.trim()) { uni.showToast({ title: "请è¾å ¥å®¡æ¹æ é¢", icon: "none" }); return false; } for (const field of formConfigData.value.fields) { if (!field.required) continue; const val = formValues[field.key]; if (val === undefined || val === null || String(val).trim() === "") { uni.showToast({ title: `请填å${field.label}`, icon: "none" }); return false; } } if (!detail.value?.nodes?.length) { uni.showToast({ title: "æ¨¡æ¿æªé ç½®å®¡æ¹æµç¨", icon: "none" }); return false; } return true; }; const buildFormConfigPayload = () => JSON.stringify({ prompt: formConfigData.value.prompt, fields: formConfigData.value.fields.map(field => ({ ...field, value: formValues[field.key] ?? "", })), }); const buildSavePayload = () => ({ templateId: detail.value.id, templateName: detail.value.templateName, businessType: detail.value.businessType, title: form.title.trim(), status: "PENDING", currentLevel: 1, applicantId: userStore.id, applicantName: applicantName.value, applyTime: parseTime(new Date()), deptId: userStore.currentDeptId || undefined, formConfig: buildFormConfigPayload(), }); const buildUpdatePayload = () => { const row = instanceRow.value || {}; return { id: instanceId.value, instanceNo: row.instanceNo, templateId: row.templateId ?? detail.value?.id, templateName: row.templateName ?? detail.value?.templateName, businessId: row.businessId, businessType: row.businessType, title: form.title.trim(), status: row.status || "PENDING", currentLevel: row.currentLevel, applicantId: row.applicantId, applicantName: row.applicantName, applyTime: row.applyTime, deptId: row.deptId, formConfig: buildFormConfigPayload(), }; }; const handleSubmit = () => { if (!validateForm() || submitting.value) return; submitting.value = true; const submitApi = isEditMode.value ? updateApprovalInstance : saveApprovalInstance; const payload = isEditMode.value ? buildUpdatePayload() : buildSavePayload(); submitApi(payload) .then(() => { uni.showToast({ title: isEditMode.value ? "ä¿åæå" : "æäº¤æå", icon: "success", }); if (isEditMode.value) { uni.removeStorageSync(EDIT_STORAGE_KEY); } setTimeout(() => { uni.navigateBack({ delta: isEditMode.value ? 1 : 2 }); }, 300); }) .catch(() => { uni.showToast({ title: isEditMode.value ? "ä¿å失败" : "æäº¤å¤±è´¥", icon: "none", }); }) .finally(() => { submitting.value = false; }); }; const loadTemplateDetail = () => { if (!templateId.value) return Promise.resolve(); return getApprovalTemplateDetail(templateId.value) .then(res => { detail.value = res?.data || null; if (!detail.value) { uni.showToast({ title: "æªè·åå°æ¨¡æ¿è¯¦æ ", icon: "none" }); } return detail.value; }) .catch(() => { uni.showToast({ title: "è·å模æ¿è¯¦æ 失败", icon: "none" }); return null; }); }; const loadForCreate = async () => { loading.value = true; detail.value = null; try { await loadTemplateDetail(); if (!detail.value) return; initFormValues(formConfigData.value.fields); if (!form.title && detail.value.templateName) { form.title = `${detail.value.templateName}ç³è¯·`; } } finally { loading.value = false; } }; const loadForEdit = async () => { const row = uni.getStorageSync(EDIT_STORAGE_KEY); if (!row || String(row.id) !== String(instanceId.value)) { uni.showToast({ title: "æªè·åå°å®¡æ¹æ°æ®", icon: "none" }); return; } uni.removeStorageSync(EDIT_STORAGE_KEY); instanceRow.value = row; templateId.value = row.templateId; form.title = row.title || ""; loading.value = true; detail.value = null; try { await loadTemplateDetail(); initFormValues(formConfigData.value.fields); } finally { loading.value = false; } }; const goBack = () => { uni.navigateBack(); }; onLoad(options => { if (options?.id) { instanceId.value = options.id; loadForEdit(); return; } if (options?.templateId) { templateId.value = options.templateId; loadForCreate(); return; } uni.showToast({ title: "缺å°é¡µé¢åæ°", icon: "none" }); }); </script> <style scoped lang="scss"> .approve-apply-page { display: flex; flex-direction: column; min-height: 100vh; background: #f0f3f8; } .form-scroll { flex: 1; height: 0; padding: 10px 12px calc(96px + env(safe-area-inset-bottom)); } .loading-wrap { padding: 48px 0; display: flex; flex-direction: column; align-items: center; gap: 12px; } .loading-text { font-size: 14px; color: #909399; } .section { background: #fff; border-radius: 12px; margin-bottom: 10px; overflow: hidden; box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05); } .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; } .form-prompt { margin: 12px 16px 0; padding: 10px 12px; font-size: 13px; color: #606266; background: #f8fafc; border-radius: 8px; line-height: 1.5; } .date-trigger { width: 100%; } .flow-list { padding: 12px; } .flow-card { padding: 12px; margin-bottom: 8px; background: #f8fafc; border-radius: 8px; border: 1px solid #eef2f6; &:last-child { margin-bottom: 0; } } .flow-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .flow-level { font-size: 14px; font-weight: 600; color: #303133; } .flow-type { font-size: 13px; color: #2979ff; } .approver-tags { display: flex; flex-wrap: wrap; gap: 8px; } .approver-tag { padding: 4px 10px; font-size: 13px; color: #303133; background: #fff; border: 1px solid #dce8f8; border-radius: 16px; } .empty-hint { padding: 12px 16px 16px; font-size: 13px; color: #909399; &.inline { padding: 0; } } .empty-wrap { padding: 48px 20px; } </style> src/pages/oa/ApproveManage/approve-list/index.vue
@@ -3,16 +3,297 @@ è·¯ç±ï¼/pages/oa/ApproveManage/approve-list/index --> <template> <OaListPage v-if="config" :page-key="pageKey" :page-config="config" /> <view class="approve-list-page sales-account"> <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> </view> <scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore"> <view v-if="list.length" class="ledger-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" /> </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> </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> </view> <up-loadmore :status="pageStatus" /> </view> <view v-else class="empty-wrap"> <up-empty mode="list" text="ææ å®¡æ¹æ°æ®" /> </view> </scroll-view> <view class="fab-button" @click="goAdd"> <up-icon name="plus" size="28" color="#ffffff" /> </view> </view> </template> <script setup> /** OA - 审æ¹ç®¡ç - 审æ¹å表 */ import OaListPage from "../../_components/OaListPage.vue"; import { useOaPage } from "../../_utils/useOaPage.js"; import { 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"; const pageKey = "ApproveManage/approve-list"; const { config } = useOaPage(pageKey); const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row"; const userStore = useUserStore(); const queryParams = reactive({ keyword: "", }); const list = ref([]); const pageStatus = ref("loadmore"); const page = reactive({ current: 1, size: 10, total: 0, }); const STATUS_TEXT = { PENDING: "è¿è¡ä¸", APPROVED: "å·²éè¿", REJECTED: "已驳å", }; 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 currentApproverName = item => { const tasks = item?.tasks; if (!Array.isArray(tasks) || !tasks.length) return "-"; const pending = tasks.find(t => t.taskStatus === "PENDING"); if (pending?.approverName) return pending.approverName; const names = [...new Set(tasks.map(t => t.approverName).filter(Boolean))]; return names.length ? names.join("ã") : "-"; }; const buildListParams = () => { const keyword = queryParams.keyword?.trim(); const dto = {}; if (keyword) { if (/[\u4e00-\u9fa5]/.test(keyword)) { dto.title = keyword; } else { dto.instanceNo = keyword; } } return { page: { current: page.current, size: page.size, }, approvalInstanceDto: 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"; } else { pageStatus.value = "loadmore"; page.current += 1; } }) .catch(() => { if (page.current === 1) { list.value = []; } pageStatus.value = "loadmore"; uni.showToast({ title: "æ¥è¯¢å¤±è´¥", icon: "none" }); }); }; const handleSearch = () => { page.current = 1; pageStatus.value = "loadmore"; list.value = []; getList(); }; const loadMore = () => { if (pageStatus.value === "loadmore") { getList(); } }; const goBack = () => { uni.navigateBack(); }; const goAdd = () => { uni.navigateTo({ url: OA_NAV.approveListTemplateSelect }); }; const canEdit = item => item?.status === "PENDING" && String(item.applicantId) === String(userStore.id); const goEdit = item => { if (!item?.id) return; uni.setStorageSync(EDIT_STORAGE_KEY, item); uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}`, }); }; const handleApprove = item => { if (!item?.id) return; uni.showToast({ title: "审æ¹è¯¦æ é¡µå¾ å¯¹æ¥", icon: "none" }); }; onShow(() => { handleSearch(); }); </script> <style scoped lang="scss"> @import "@/styles/sales-common.scss"; .approve-list-page { display: flex; flex-direction: column; min-height: 100vh; } .list-scroll { flex: 1; height: 0; padding-bottom: calc(80px + env(safe-area-inset-bottom)); } .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; } </style> src/pages/oa/ApproveManage/approve-list/template-select.vue
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,308 @@ <!-- OA / 审æ¹ç®¡ç / éæ©å®¡æ¹æ¨¡æ¿ è·¯ç±ï¼/pages/oa/ApproveManage/approve-list/template-select Tabï¼TypeEnums â businessTypeï¼å表ï¼GET /approvalTemplate/list/1ï¼èªå®ä¹å·²å¯ç¨ï¼åæ businessType çé --> <template> <view class="template-select-page sales-account"> <PageHeader title="éæ©å®¡æ¹æ¨¡æ¿" @back="goBack" /> <view v-if="typeOptions.length" class="step-section"> <view class="tabs-wrap"> <up-tabs :list="tabList" :current="activeTab" line-color="#2979ff" @click="onTabClick" /> </view> </view> <view class="search-section"> <view class="search-bar"> <view class="search-input"> <up-input v-model="keyword" class="search-text" placeholder="请è¾å ¥æ¨¡æ¿åç§°" clearable /> </view> <view class="filter-button"> <up-icon name="search" size="24" color="#999" /> </view> </view> </view> <scroll-view class="list-scroll" scroll-y :show-scrollbar="false"> <view v-if="loading" 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"> <view v-for="item in displayList" :key="item.id" class="ledger-item ledger-item--clickable" @click="selectTemplate(item)"> <view class="item-header"> <view class="item-left"> <view class="document-icon"> <up-icon name="file-text" size="16" color="#ffffff" /> </view> <text class="item-id">{{ item.templateName || "-" }}</text> </view> <u-tag :type="enabledTagType(item.enabled)" :text="enabledText(item.enabled)" /> </view> <up-divider /> <view class="item-details"> <view class="detail-row"> <text class="detail-label">审æ¹ç±»å</text> <text class="detail-value">{{ businessTypeText(item.businessType) }}</text> </view> <view class="detail-row"> <text class="detail-label">审æ¹èç¹</text> <text class="detail-value">{{ nodeCount(item) }}</text> </view> <view class="detail-row"> <text class="detail-label">模æ¿è¯´æ</text> <text class="detail-value">{{ item.description || "-" }}</text> </view> </view> </view> </view> <view v-else class="empty-wrap"> <up-empty mode="list" :text="emptyText" /> </view> </scroll-view> </view> </template> <script setup> 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, filterTemplatesByBusinessType, getBusinessTypeLabel, getDefaultTypeTabIndex, } from "../../_utils/approvalTemplateType.js"; const typeOptions = ref([]); const typeLabelMap = ref({}); /** å ¨é¨èªå®ä¹å·²å¯ç¨æ¨¡æ¿ï¼list/1 䏿¬¡æåï¼ */ const allTemplates = ref([]); const activeTab = ref(0); const keyword = ref(""); const loading = ref(false); const tabList = computed(() => typeOptions.value.map(opt => ({ name: opt.name })) ); const currentTypeOption = computed(() => typeOptions.value[activeTab.value]); const currentSource = computed(() => { const businessType = currentTypeOption.value?.value; return filterTemplatesByBusinessType(allTemplates.value, businessType); }); const displayList = computed(() => { const kw = keyword.value?.trim().toLowerCase(); if (!kw) return currentSource.value; return currentSource.value.filter(item => (item.templateName || "").toLowerCase().includes(kw) ); }); const emptyText = computed(() => { const typeName = currentTypeOption.value?.name || "该审æ¹ç±»å"; return `ææ ${typeName}ä¸ç模æ¿`; }); const businessTypeText = type => getBusinessTypeLabel(type, typeLabelMap.value); const enabledText = enabled => { const val = String(enabled ?? ""); if (val === "1") return "å¯ç¨"; if (val === "0") return "åç¨"; return "-"; }; const enabledTagType = enabled => { const val = String(enabled ?? ""); if (val === "1") return "success"; if (val === "0") return "info"; return "info"; }; const nodeCount = item => { const count = item?.nodes?.length; 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([ fetchApprovalTemplateTypes(), loadCustomTemplates(), ]); typeOptions.value = opts; typeLabelMap.value = buildTypeLabelMap(opts); activeTab.value = getDefaultTypeTabIndex(opts); } catch { typeOptions.value = []; typeLabelMap.value = {}; uni.showToast({ title: "è·å审æ¹ç±»å失败", icon: "none" }); } finally { loading.value = false; } }; const onTabClick = item => { activeTab.value = item?.index ?? 0; keyword.value = ""; }; const goBack = () => { uni.navigateBack(); }; const selectTemplate = item => { if (!item?.id) return; if (String(item.enabled) === "0") { uni.showToast({ title: "该模æ¿å·²åç¨", icon: "none" }); return; } uni.navigateTo({ url: `${OA_NAV.approveListApply}?templateId=${item.id}`, }); }; onLoad(() => { initPage(); }); </script> <style scoped lang="scss"> @import "@/styles/sales-common.scss"; .template-select-page { display: flex; flex-direction: column; min-height: 100vh; } .step-section { background: #fff; border-bottom: 1px solid #f0f0f0; } .step-label { display: block; padding: 10px 16px 0; font-size: 13px; font-weight: 600; color: #303133; } .step-hint { display: flex; align-items: baseline; justify-content: space-between; padding: 10px 16px 4px; gap: 8px; } .step-desc { flex-shrink: 0; font-size: 12px; color: #909399; } .tabs-wrap { padding: 0 12px 4px; } .list-scroll { flex: 1; height: 0; padding-bottom: env(safe-area-inset-bottom); } .loading-wrap { padding: 48px 0; display: flex; flex-direction: column; align-items: center; gap: 12px; } .loading-text { font-size: 14px; color: #909399; } .empty-wrap { padding: 48px 20px; } .ledger-item--clickable:active { opacity: 0.92; } .card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e8ecf0; } .card-footer-tip { font-size: 13px; color: #2979ff; } </style> src/pages/oa/ApproveManage/approve-template/detail.vue
@@ -24,8 +24,8 @@ <text class="info-value">{{ detail.templateName || "-" }}</text> </view> <view class="info-item"> <text class="info-label">模æ¿ç±»å</text> <text class="info-value">{{ templateTypeText(detail.templateType) }}</text> <text class="info-label">审æ¹ç±»å</text> <text class="info-value">{{ businessTypeText(detail.businessType) }}</text> </view> <view class="info-item"> <text class="info-label">å¯ç¨ç¶æ</text> @@ -125,6 +125,11 @@ import PageHeader from "@/components/PageHeader.vue"; import FooterButtons from "@/components/FooterButtons.vue"; import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js"; import { buildTypeLabelMap, fetchApprovalTemplateTypes, getTemplateTypeLabel, } from "../../_utils/approvalTemplateType.js"; const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; const LEVEL_TEXT = ["", "ä¸", "äº", "ä¸", "å", "äº", "å ", "ä¸", "å «", "ä¹", "å"]; @@ -139,6 +144,7 @@ const templateId = ref(""); const detail = ref(null); const loading = ref(false); const typeLabelMap = ref({}); const formConfigData = computed(() => { const raw = detail.value?.formConfig; @@ -156,12 +162,8 @@ const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n); const templateTypeText = type => { const val = Number(type); if (val === 0) return "ç³»ç»å ç½®"; if (val === 1) return "èªå®ä¹"; return "-"; }; const businessTypeText = type => getTemplateTypeLabel(type, typeLabelMap.value); const enabledText = enabled => { const val = String(enabled ?? ""); @@ -213,6 +215,11 @@ }; onLoad(options => { fetchApprovalTemplateTypes() .then(opts => { typeLabelMap.value = buildTypeLabelMap(opts); }) .catch(() => {}); if (options?.id) { templateId.value = options.id; loadDetail(); src/pages/oa/ApproveManage/approve-template/edit.vue
@@ -28,19 +28,18 @@ maxlength="50" clearable /> </up-form-item> <up-form-item label="模æ¿ç±»å" prop="templateType" <up-form-item label="审æ¹ç±»å" prop="businessType" required class="form-item-type"> <up-radio-group v-model="form.templateType" class="type-radio-group" placement="row" @change="onTemplateTypeChange"> <up-radio v-for="opt in TEMPLATE_TYPE_OPTIONS" :key="opt.value" :name="opt.value" :label="opt.name" /> </up-radio-group> class="form-item-select" @click="openBusinessTypeSheet"> <up-input :model-value="businessTypeText" placeholder="è¯·éæ©å®¡æ¹ç±»å" readonly /> <template #right> <up-icon name="arrow-right" @click.stop="openBusinessTypeSheet" /> </template> </up-form-item> <up-form-item label="å¯ç¨ç¶æ" class="form-item-switch"> @@ -311,6 +310,12 @@ </scroll-view> </view> </up-popup> <up-action-sheet :show="showBusinessTypeSheet" title="鿩审æ¹ç±»å" :actions="businessTypeActions" @select="onSelectBusinessType" @close="showBusinessTypeSheet = false" /> </view> </template> @@ -325,6 +330,7 @@ } from "@/api/oa/approvalTemplate.js"; import { userListNoPageByTenantId } from "@/api/system/user"; import { formatDateToYMD } from "@/utils/ruoyi"; import { fetchApprovalTemplateTypes } from "../../_utils/approvalTemplateType.js"; const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; @@ -384,6 +390,7 @@ const form = reactive({ templateName: "", businessType: null, templateType: 1, enabled: "1", description: "", @@ -413,11 +420,11 @@ const rules = { templateName: [{ required: true, message: "请è¾å ¥æ¨¡æ¿åç§°", trigger: "blur" }], templateType: [ businessType: [ { validator: (_rule, value, callback) => { if (value === "" || value === null || value === undefined) { callback(new Error("è¯·éæ©æ¨¡æ¿ç±»å")); callback(new Error("è¯·éæ©å®¡æ¹ç±»å")); return; } callback(); @@ -427,10 +434,22 @@ ], }; const TEMPLATE_TYPE_OPTIONS = [ { name: "ç³»ç»å ç½®", value: 0 }, { name: "èªå®ä¹", value: 1 }, ]; const businessTypeOptions = ref([]); const showBusinessTypeSheet = ref(false); const businessTypeActions = computed(() => businessTypeOptions.value.map(opt => ({ name: opt.name, value: opt.value, })) ); const businessTypeText = computed(() => { const matched = businessTypeOptions.value.find( opt => String(opt.value) === String(form.businessType) ); return matched?.name || ""; }); const presetActions = FORM_PRESETS.map(item => ({ name: item.name, @@ -487,10 +506,12 @@ if (!row) return; templateId.value = row.id; form.templateName = row.templateName || ""; form.templateType = row.templateType === 0 || row.templateType === 1 ? row.templateType : Number(row.templateType) || 1; const parsedBusiness = Number(row.businessType); form.businessType = Number.isNaN(parsedBusiness) ? row.businessType : parsedBusiness; const parsedTemplateType = Number(row.templateType); form.templateType = Number.isNaN(parsedTemplateType) ? 1 : parsedTemplateType; form.enabled = String(row.enabled ?? "1"); form.description = row.description || ""; @@ -526,8 +547,18 @@ uni.navigateBack(); }; const onTemplateTypeChange = () => { formRef.value?.validateField?.("templateType"); const openBusinessTypeSheet = () => { if (!businessTypeOptions.value.length) { uni.showToast({ title: "审æ¹ç±»åå è½½ä¸", icon: "none" }); return; } showBusinessTypeSheet.value = true; }; const onSelectBusinessType = action => { form.businessType = action.value; showBusinessTypeSheet.value = false; formRef.value?.validateField?.("businessType"); }; const onSelectPreset = action => { @@ -710,6 +741,7 @@ templateName: form.templateName.trim(), enabled: form.enabled, description: form.description?.trim() || "", businessType: form.businessType, templateType: form.templateType, formConfig: JSON.stringify({ prompt: formConfig.prompt?.trim() || "", @@ -792,7 +824,25 @@ } }); const loadTemplateTypes = () => fetchApprovalTemplateTypes() .then(opts => { businessTypeOptions.value = opts; if (!templateId.value && opts.length) { const matched = opts.some( opt => String(opt.value) === String(form.businessType) ); if (!matched) { form.businessType = opts[0].value; } } }) .catch(() => { uni.showToast({ title: "è·å审æ¹ç±»å失败", icon: "none" }); }); onMounted(() => { loadTemplateTypes(); userListNoPageByTenantId() .then(res => { userList.value = res?.data || []; @@ -926,18 +976,18 @@ font-size: 15px !important; } :deep(.form-item-type .u-form-item__body) { :deep(.form-item-select .u-form-item__body) { align-items: center !important; } .type-radio-group { display: flex; justify-content: flex-end; flex-wrap: nowrap; :deep(.form-item-select .u-form-item__content) { flex: 1 !important; min-width: 0 !important; justify-content: flex-end !important; } :deep(.type-radio-group .u-radio) { margin-left: 20px; :deep(.form-item-select .u-input__content__field-wrapper__field) { text-align: right !important; } :deep(.form-item-switch .u-form-item__body) { src/pages/oa/ApproveManage/approve-template/index.vue
@@ -48,8 +48,8 @@ <up-divider /> <view class="item-details"> <view class="detail-row"> <text class="detail-label">模æ¿ç±»å</text> <text class="detail-value">{{ templateTypeText(item.templateType) }}</text> <text class="detail-label">审æ¹ç±»å</text> <text class="detail-value">{{ businessTypeText(item.businessType) }}</text> </view> <view class="detail-row"> <text class="detail-label">审æ¹èç¹</text> @@ -115,8 +115,14 @@ deleteApprovalTemplate, listApprovalTemplatePage, } from "@/api/oa/approvalTemplate.js"; import { buildTypeLabelMap, fetchApprovalTemplateTypes, getTemplateTypeLabel, } from "../../_utils/approvalTemplateType.js"; const EDIT_STORAGE_KEY = "oa_approve_template_edit_row"; const typeLabelMap = ref({}); const queryParams = reactive({ templateName: "", @@ -155,12 +161,15 @@ return "info"; }; const templateTypeText = type => { const val = Number(type); if (val === 0) return "ç³»ç»å ç½®"; if (val === 1) return "èªå®ä¹"; return "-"; }; const businessTypeText = type => getTemplateTypeLabel(type, typeLabelMap.value); const loadTemplateTypes = () => fetchApprovalTemplateTypes() .then(opts => { typeLabelMap.value = buildTypeLabelMap(opts); }) .catch(() => {}); const nodeCount = item => { const count = item?.nodes?.length; @@ -266,6 +275,7 @@ }; onShow(() => { loadTemplateTypes(); handleSearch(); }); </script> src/pages/oa/_utils/approvalTemplateType.js
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,94 @@ import { getTypeEnums } from "@/api/basic/enum.js"; /** * GET /approvalTemplate/list/{type} è·¯å¾åæ°ä¸º templateType * 1 = èªå®ä¹ä¸å·²å¯ç¨ï¼ä¸ businessType æ å ³ï¼ */ export const CUSTOM_TEMPLATE_LIST_TYPE = 1; /** ä¸å¡ç±»åæä¸¾å åºï¼approveTypeï¼1å ¬åº 2请å â¦ï¼ */ export const FALLBACK_BUSINESS_TYPE_OPTIONS = [ { name: "å ¬åºç®¡ç", value: 1 }, { name: "请å管ç", value: 2 }, ]; /** å° /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 { name: String(name), value: Number.isNaN(num) ? value : num, }; }); } return []; } /** æåä¸å¡ç±»åæä¸¾ï¼TypeEnums â businessTypeï¼ */ export async function fetchApprovalTemplateTypes() { const res = await getTypeEnums(); const options = normalizeEnumOptions(res?.data); return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS]; } /** æ businessType ç鿍¡æ¿ */ export function filterTemplatesByBusinessType(templates, businessType) { if (businessType == null || businessType === "") return []; return (templates || []).filter( item => String(item.businessType) === String(businessType) ); } /** é»è®¤ Tab 䏿 ï¼å¯æä¸å¡ç±»å value æå®ï¼é»è®¤ç¬¬ä¸é¡¹ï¼ */ export function getDefaultTypeTabIndex(options, defaultBusinessType) { if (!options?.length) return 0; if (defaultBusinessType == null) return 0; const idx = options.findIndex( opt => String(opt.value) === String(defaultBusinessType) ); return idx >= 0 ? idx : 0; } export function buildTypeLabelMap(options) { const map = {}; (options || []).forEach(opt => { map[String(opt.value)] = opt.name; }); return map; } /** æ ¹æ® businessType æ¾ç¤ºä¸å¡ç±»ååç§° */ export function getTemplateTypeLabel(type, labelMap) { if (type == null || type === "") return "-"; return labelMap?.[String(type)] ?? String(type); } export const getBusinessTypeLabel = getTemplateTypeLabel;