This commit is contained in:
hero920103 committed 2026-11-14 07:54:01 +08:00
1 parent 4ab63e77ed
commit eb9b7beff5
16 files changed
+1031 -91

No files matched your search

+86 -1
View File
@@ -123,6 +123,89 @@ function mergeFixedCmsMenu(menus) {
: [fixedCmsMenu, ...menus]; : [fixedCmsMenu, ...menus];
} }
const ERP_ROOT = "/apps/erp";
const ERP_WORKBENCH_DIR = "/apps/erp/workbench";
const ERP_DASHBOARD_PAGE = "/apps/erp/dashboard";
const ERP_APPROVAL_PAGE = "/apps/erp/workbench/approval";
/**
* 进销存「工作台」统一为目录,目录内固定挂「工作台 / 审批列表」两个页面。
*
* 菜单由数据库下发,erp_menus.sql 未重跑时工作台仍是模块根下的一个叶子页面,
* 这里在导航模型里就地补齐目录结构,保证前端展示与 SQL 的目标结构一致;
* 数据库已是正确的目录结构时只做幂等补充,不会重复插入。
*/
function normalizeErpWorkbench(menus) {
return (menus || []).map((menu) => {
if (menu.path !== ERP_ROOT) {
return menu.children?.length
? { ...menu, children: normalizeErpWorkbench(menu.children) }
: menu;
}
const children = [...(menu.children || [])];
const dir = children.find((m) => m.path === ERP_WORKBENCH_DIR && m.children?.length);
const dashboard = children.find((m) => m.path === ERP_DASHBOARD_PAGE);
const pages = dir ? [...(dir.children || [])] : [];
// 不能用 isWorkbenchMenu 判断:审批列表路径里也带 workbench,会被误判成工作台页
const hasDashboard = pages.some(
(m) => m.path === ERP_DASHBOARD_PAGE || String(m.title || "") === "工作台"
);
if (!hasDashboard) {
pages.push(
dashboard
? { ...dashboard, title: "工作台", sort: 1, order: 1 }
: {
id: -3001,
path: ERP_DASHBOARD_PAGE,
title: "工作台",
icon: "DataBoard",
component_path: "/apps/erp/dashboard/index.vue",
type: 2,
sort: 1,
order: 1,
children: [],
}
);
}
if (!pages.some((m) => m.path === ERP_APPROVAL_PAGE)) {
pages.push({
id: -3002,
path: ERP_APPROVAL_PAGE,
title: "审批列表",
icon: "Tickets",
component_path: "/apps/erp/workbench/approvalList.vue",
type: 2,
sort: 2,
order: 2,
children: [],
});
}
const workbenchDir = dir
? { ...dir, type: 1, component_path: "", children: pages }
: {
id: -300,
pid: menu.id,
path: ERP_WORKBENCH_DIR,
title: "工作台",
icon: "DataBoard",
component_path: "",
type: 1,
sort: 1,
order: 1,
children: pages,
};
// 原工作台叶子(或空的工作台目录)已被收进新目录,避免同一功能出现两份
const rest = children.filter(
(m) => m.path !== ERP_WORKBENCH_DIR && m.path !== ERP_DASHBOARD_PAGE
);
return { ...menu, children: [workbenchDir, ...rest] };
});
}
function sortMenusRecursively(menus) { function sortMenusRecursively(menus) {
(menus || []).forEach((menu) => { (menus || []).forEach((menu) => {
if (menu.children && menu.children.length > 0) { if (menu.children && menu.children.length > 0) {
@@ -260,7 +343,9 @@ export function useAdminNavigation() {
const menuStore = useMenuStore(); const menuStore = useMenuStore();
const list = computed(() => const list = computed(() =>
sortMenusRecursively(processMenus(mergeFixedCmsMenu(menuStore.menus || []))), sortMenusRecursively(
processMenus(normalizeErpWorkbench(mergeFixedCmsMenu(menuStore.menus || [])))
),
); );
const currentModule = computed(() => { const currentModule = computed(() => {
+19 -2
View File
@@ -22,6 +22,19 @@ const staticMainChildren = [
component: () => import("@/views/system/approval/index.vue"), component: () => import("@/views/system/approval/index.vue"),
meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" } meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" }
}, },
// ERP 工作台目录内的两个页面:静态注册保证 URL 直达,菜单入口由数据库驱动
{
path: "/apps/erp/dashboard",
name: "ErpWorkbenchDashboard",
component: () => import("@/views/apps/erp/dashboard/index.vue"),
meta: { requiresAuth: true, title: "工作台", modulePath: "/apps/erp" }
},
{
path: "/apps/erp/workbench/approval",
name: "ErpWorkbenchApprovalList",
component: () => import("@/views/apps/erp/workbench/approvalList.vue"),
meta: { requiresAuth: true, title: "审批列表", modulePath: "/apps/erp" }
},
// OA 审批中心:待我审批 / 审批列表 / 审批设置(静态注册保证 URL 直达) // OA 审批中心:待我审批 / 审批列表 / 审批设置(静态注册保证 URL 直达)
{ {
path: "/apps/oa/approvalcenter/mine", path: "/apps/oa/approvalcenter/mine",
@@ -263,10 +276,14 @@ export async function loadAndAddDynamicRoutes() {
return Promise.resolve(); return Promise.resolve();
} }
} catch (error) { } catch (error) {
console.error('加载动态路由失败:', error); // 菜单接口异常时不要把错误抛给导航守卫,否则应用会卡在白屏;
// 保留内置静态路由(工作台、审批列表、审批流配置等)继续可用。
// 不重置 dynamicRoutesAdded,避免每次路由切换都重试失败的接口,
// 刷新页面或手动刷新菜单即可重新拉取。
console.error('加载动态路由失败,已保留内置路由:', error);
dynamicRoutesAdded = true; dynamicRoutesAdded = true;
routesLoadingPromise = null; routesLoadingPromise = null;
throw error; return Promise.resolve();
} }
})(); })();
+6 -3
View File
@@ -132,9 +132,11 @@ export const useMenuStore = defineStore('menu', () => {
// 用户登录,使用 getMenus 接口 // 用户登录,使用 getMenus 接口
res = await getMenus(userInfo.id); res = await getMenus(userInfo.id);
// 检查响应格式 // 检查响应格式:HTTP 200 但 body 为空(后端/网关异常)会走到这里,
// 打印原始值便于区分「服务挂了」和「返回了空内容」
if (!res) { if (!res) {
throw new Error('获取菜单失败:服务器无响应'); console.error('菜单接口返回空响应:', { res, url: `/backend/menu/${userInfo.id}` });
throw new Error('获取菜单失败:接口返回空响应(后端服务或网关异常)');
} }
// 检查后端返回的 code 字段 // 检查后端返回的 code 字段
@@ -215,8 +217,9 @@ export const useMenuStore = defineStore('menu', () => {
}; };
// 刷新菜单(强制从 API 获取) // 刷新菜单(强制从 API 获取)
// 不预先清缓存:接口异常时 fetchMenus 还能回退到上一份菜单,避免整个应用没菜单可用;
// 请求成功后 saveToCache 会直接覆盖旧缓存。
const refreshMenus = async () => { const refreshMenus = async () => {
clearCache();
return await fetchMenus(true); return await fetchMenus(true);
}; };
@@ -1,5 +1,10 @@
<template> <template>
<div v-loading="loading" class="erp-dashboard"> <div v-loading="loading" class="erp-dashboard">
<!-- 工作台直达审批列表:菜单尚未刷新时也能进入待办审批 -->
<div class="approval-entry">
<span>单据审批待办</span>
<el-button type="primary" size="small" @click="goApproval">进入审批列表</el-button>
</div>
<!-- 第一行统计卡片 --> <!-- 第一行统计卡片 -->
<el-row :gutter="16" class="stat-row"> <el-row :gutter="16" class="stat-row">
<el-col :span="4" :xs="12" v-for="(item, index) in firstRowStats" :key="'first-' + index"> <el-col :span="4" :xs="12" v-for="(item, index) in firstRowStats" :key="'first-' + index">
@@ -58,6 +63,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue'; import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { InfoFilled } from '@element-plus/icons-vue'; import { InfoFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
@@ -66,6 +72,8 @@ import { getErpDashboard } from '@/api/erpReport';
type Stat = { label: string; text: string; prefix?: string }; type Stat = { label: string; text: string; prefix?: string };
const loading = ref(false); const loading = ref(false);
const router = useRouter();
const goApproval = () => router.push('/apps/erp/workbench/approval');
// 统计卡片数据 - 第一行(金额) // 统计卡片数据 - 第一行(金额)
const firstRowStats = ref<Stat[]>([]); const firstRowStats = ref<Stat[]>([]);
@@ -255,6 +263,16 @@ onUnmounted(() => {
// min-height: calc(100vh - 84px); // min-height: calc(100vh - 84px);
} }
.approval-entry {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.stat-row { .stat-row {
.el-col { .el-col {
margin-bottom: 0; margin-bottom: 0;
@@ -29,9 +29,14 @@
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item :label="isTransfer ? '调出仓库' : '仓库'" required> <el-form-item :label="isTransfer ? '调出仓库' : '仓库'" required>
<el-select v-model="form.warehouse_id" placeholder="请选择仓库" style="width: 100%" @change="onWarehouseChange"> <div class="field-with-action">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" /> <el-select v-model="form.warehouse_id" placeholder="请选择仓库" class="field-select" @change="onWarehouseChange">
</el-select> <el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
<el-tooltip content="快捷新增仓库" placement="top">
<el-button :icon="Plus" aria-label="快捷新增仓库" @click="openQuickWarehouse" />
</el-tooltip>
</div>
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -50,16 +55,21 @@
<el-col v-if="partnerType" :span="8"> <el-col v-if="partnerType" :span="8">
<el-form-item :label="partnerType === 'supplier' ? '供应商' : '客户'" required> <el-form-item :label="partnerType === 'supplier' ? '供应商' : '客户'" required>
<el-select <div class="field-with-action">
v-model="form.partner_id" <el-select
filterable v-model="form.partner_id"
clearable filterable
placeholder="搜索名称选择" clearable
style="width: 100%" placeholder="搜索名称选择"
@change="onPartnerChange" class="field-select"
> @change="onPartnerChange"
<el-option v-for="p in partners" :key="p.id" :label="partnerLabel(p)" :value="p.id" /> >
</el-select> <el-option v-for="p in partners" :key="p.id" :label="partnerLabel(p)" :value="p.id" />
</el-select>
<el-tooltip v-if="partnerType === 'customer'" content="快捷新增客户" placement="top">
<el-button :icon="Plus" aria-label="快捷新增客户" @click="openQuickCustomer" />
</el-tooltip>
</div>
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -199,12 +209,16 @@
</div> </div>
</div> </div>
<QuickCreateWarehouse v-model:visible="quickWarehouseVisible" @created="onWarehouseCreated" />
<QuickCreateCustomer v-model:visible="quickCustomerVisible" @created="onCustomerCreated" />
<template #footer> <template #footer>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button @click="close">取消</el-button> <el-button @click="close">取消</el-button>
<el-button :loading="saving" @click="save('draft')">保存草稿</el-button> <el-button :loading="saving" @click="save('draft')">保存草稿</el-button>
<el-button v-if="flows.length" type="warning" :loading="saving" @click="save('submit')">保存并提交审批</el-button> <el-button v-if="flows.length" type="warning" :loading="saving" @click="save('submit')">保存并提交审批</el-button>
<el-button type="primary" :loading="saving" @click="save('approve')">保存并审核生效</el-button> <!-- 配了审批流的单据只能提交审批,由工作台审批列表处理;未配流程时保留直接生效兜底 -->
<el-button v-if="!flows.length" type="primary" :loading="saving" @click="save('approve')">保存并生效</el-button>
</div> </div>
</template> </template>
</el-drawer> </el-drawer>
@@ -215,7 +229,10 @@ import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Download, DocumentCopy } from '@element-plus/icons-vue' import { Plus, Download, DocumentCopy } from '@element-plus/icons-vue'
import ProductSelect from './ProductSelect.vue' import ProductSelect from './ProductSelect.vue'
import QuickCreateWarehouse from './QuickCreateWarehouse.vue'
import QuickCreateCustomer from './QuickCreateCustomer.vue'
import { import {
DOC_STATUS_VALUE,
dateOnly, dateOnly,
docTypeLabel, docTypeLabel,
docTypePartner, docTypePartner,
@@ -241,10 +258,12 @@ const props = defineProps({
/** 预填明细(补货建议转采购单用):[{ product_id, product_name, spec, unit, quantity, unit_price }] */ /** 预填明细(补货建议转采购单用):[{ product_id, product_name, spec, unit, quantity, unit_price }] */
presetItems: { type: Array, default: null } presetItems: { type: Array, default: null }
}) })
const emit = defineEmits(['update:visible', 'success']) const emit = defineEmits(['update:visible', 'success', 'warehouse-created'])
let rowSeq = 0 let rowSeq = 0
const warehouses = ref([]) const warehouses = ref([])
const quickWarehouseVisible = ref(false)
const quickCustomerVisible = ref(false)
const partners = ref([]) const partners = ref([])
const sourceDocs = ref([]) const sourceDocs = ref([])
const flows = ref([]) const flows = ref([])
@@ -261,7 +280,7 @@ const form = reactive({
warehouse_id: 0, warehouse_id: 0,
to_warehouse_id: 0, to_warehouse_id: 0,
partner_type: '', partner_type: '',
partner_id: 0, partner_id: '',
partner_name: '', partner_name: '',
source_doc_id: 0, source_doc_id: 0,
discount_amount: 0, discount_amount: 0,
@@ -332,7 +351,8 @@ async function loadPartners() {
async function loadSourceDocs() { async function loadSourceDocs() {
try { try {
const res = await getDocumentList({ doc_types: sourceTypes.value.join(','), status: 2, pageSize: 50 }) // 来源单据只列已生效的,与下推入口的状态限制保持一致
const res = await getDocumentList({ doc_types: sourceTypes.value.join(','), status: DOC_STATUS_VALUE.APPROVED, pageSize: 50 })
if (res.code === 200) sourceDocs.value = res.data.list || [] if (res.code === 200) sourceDocs.value = res.data.list || []
} catch (e) { } catch (e) {
sourceDocs.value = [] sourceDocs.value = []
@@ -357,7 +377,7 @@ function resetForm() {
form.warehouse_id = src ? Number(src.warehouse_id) || 0 : form.warehouse_id || 0 form.warehouse_id = src ? Number(src.warehouse_id) || 0 : form.warehouse_id || 0
form.to_warehouse_id = src ? Number(src.to_warehouse_id) || 0 : 0 form.to_warehouse_id = src ? Number(src.to_warehouse_id) || 0 : 0
form.partner_type = partnerType.value form.partner_type = partnerType.value
form.partner_id = src ? Number(src.partner_id) || 0 : 0 form.partner_id = src ? Number(src.partner_id) || '' : ''
form.partner_name = src ? src.partner_name || '' : '' form.partner_name = src ? src.partner_name || '' : ''
form.source_doc_id = src ? Number(src.source_doc_id) || 0 : 0 form.source_doc_id = src ? Number(src.source_doc_id) || 0 : 0
form.discount_amount = src ? Number(src.discount_amount) || 0 : 0 form.discount_amount = src ? Number(src.discount_amount) || 0 : 0
@@ -433,7 +453,7 @@ async function syncBatchFlags(rows) {
function applyPushFrom(pf) { function applyPushFrom(pf) {
const src = pf.doc || {} const src = pf.doc || {}
form.partner_type = src.partner_type || partnerType.value form.partner_type = src.partner_type || partnerType.value
form.partner_id = Number(src.partner_id) || 0 form.partner_id = Number(src.partner_id) || ''
form.partner_name = src.partner_name || '' form.partner_name = src.partner_name || ''
form.warehouse_id = Number(src.warehouse_id) || form.warehouse_id form.warehouse_id = Number(src.warehouse_id) || form.warehouse_id
form.source_doc_id = Number(src.id) || 0 form.source_doc_id = Number(src.id) || 0
@@ -477,6 +497,39 @@ function onWarehouseChange() {
if (isTransfer.value && form.to_warehouse_id === form.warehouse_id) form.to_warehouse_id = 0 if (isTransfer.value && form.to_warehouse_id === form.warehouse_id) form.to_warehouse_id = 0
} }
function openQuickWarehouse() {
quickWarehouseVisible.value = true
}
/** 快捷新增后刷新仓库下拉并自动选中,避免用户还要手动去仓库档案页建仓 */
async function onWarehouseCreated(row) {
emit('warehouse-created', row)
try {
const res = await getWarehouseList({ pageSize: 100 })
if (res.code === 200) warehouses.value = res.data.list || []
} catch (e) {
ElMessage.error(e.message || '仓库列表刷新失败')
}
if (!warehouses.value.some((w) => String(w.id) === String(row.id))) warehouses.value.push(row)
form.warehouse_id = Number(row.id) || 0
onWarehouseChange()
}
function openQuickCustomer() {
quickCustomerVisible.value = true
}
/** 快捷新增客户后刷新往来单位下拉并自动选中,保持开单不中断 */
async function onCustomerCreated(row) {
await loadPartners()
if (!partners.value.some((p) => String(p.id) === String(row.id))) partners.value.push(row)
form.partner_id = Number(row.id) || ''
form.partner_name = row.customer_name || ''
// 与手动选择客户保持一致:刷新来源单据,并清掉上一家客户残留的信用提示
loadSourceDocs()
loadCredit()
}
function onPartnerChange(id) { function onPartnerChange(id) {
const p = partners.value.find((x) => String(x.id) === String(id)) const p = partners.value.find((x) => String(x.id) === String(id))
form.partner_name = p ? partnerLabel(p) : '' form.partner_name = p ? partnerLabel(p) : ''
@@ -608,7 +661,7 @@ function buildPayload() {
warehouse_id: form.warehouse_id, warehouse_id: form.warehouse_id,
to_warehouse_id: form.to_warehouse_id, to_warehouse_id: form.to_warehouse_id,
partner_type: partnerType.value, partner_type: partnerType.value,
partner_id: form.partner_id, partner_id: Number(form.partner_id) || 0,
source_doc_id: form.source_doc_id, source_doc_id: form.source_doc_id,
// 调拨/盘点等不涉及成交价的单据不允许有折扣,否则会被后端计入金额 // 调拨/盘点等不涉及成交价的单据不允许有折扣,否则会被后端计入金额
discount_amount: showPrice.value ? form.discount_amount : 0, discount_amount: showPrice.value ? form.discount_amount : 0,
@@ -663,6 +716,8 @@ async function save(mode) {
} }
function close() { function close() {
quickWarehouseVisible.value = false
quickCustomerVisible.value = false
emit('update:visible', false) emit('update:visible', false)
} }
</script> </script>
@@ -676,6 +731,19 @@ function close() {
margin-bottom: 8px; margin-bottom: 8px;
} }
// 仓库、客户等下拉右侧挂「+」快捷新增按钮时共用
.field-with-action {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
.field-select {
flex: 1;
min-width: 0;
}
}
.credit-tip { .credit-tip {
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -20,6 +20,10 @@
</div> </div>
</div> </div>
<el-tabs v-model="activeStatus" class="status-tabs">
<el-tab-pane v-for="t in statusTabs" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<div class="filter-bar"> <div class="filter-bar">
<el-form :inline="true" @submit.prevent> <el-form :inline="true" @submit.prevent>
<el-form-item v-if="typeOptions.length > 1" label="单据类型"> <el-form-item v-if="typeOptions.length > 1" label="单据类型">
@@ -37,11 +41,6 @@
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px" @change="handleSearch">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="仓库"> <el-form-item label="仓库">
<el-select v-model="filters.warehouse_id" clearable placeholder="全部" style="width: 140px" @change="handleSearch"> <el-select v-model="filters.warehouse_id" clearable placeholder="全部" style="width: 140px" @change="handleSearch">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" /> <el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
@@ -102,22 +101,27 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="create_user_name" label="制单" width="100" show-overflow-tooltip /> <el-table-column prop="create_user_name" label="制单" width="100" show-overflow-tooltip />
<el-table-column label="操作" width="230" align="center" fixed="right"> <el-table-column label="操作" width="320" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button> <div class="row-actions">
<el-button v-if="canEdit(row)" link type="primary" size="small" @click="openEdit(row)">编辑</el-button> <el-button v-if="canEdit(row)" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canEdit(row)" link type="warning" size="small" @click="handleSubmit(row)">提交审批</el-button> <el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button v-if="canApprove(row)" link type="success" size="small" @click="handleApprove(row)">审核</el-button> <el-dropdown v-if="hasMoreActions(row)" trigger="click" @command="(cmd) => handleMoreCommand(cmd, row)">
<el-dropdown v-if="nextTypes(row).length" trigger="click" @command="(t) => openPush(row, t)"> <el-button link type="primary" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<el-button link type="primary" size="small">下推<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button> <template #dropdown>
<template #dropdown> <el-dropdown-menu>
<el-dropdown-menu> <el-dropdown-item
<el-dropdown-item v-for="n in nextTypes(row)" :key="n.type" :command="n.type">{{ n.label }}</el-dropdown-item> v-for="n in nextTypes(row)"
</el-dropdown-menu> :key="n.type"
</template> :command="'push:' + n.type"
</el-dropdown> >下推:{{ n.label }}</el-dropdown-item>
<el-button v-if="row.status !== 2 && row.status !== 4" link type="danger" size="small" @click="handleCancel(row)">作废</el-button> <el-dropdown-item v-if="canSubmit(row)" command="submit" :divided="needExtraDivider(row, 'submit')">提交审批</el-dropdown-item>
<el-button v-if="canEdit(row)" link type="danger" size="small" @click="handleDelete(row)">删除</el-button> <el-dropdown-item v-if="canCancel(row)" command="cancel" :divided="needExtraDivider(row, 'cancel')">作废</el-dropdown-item>
<el-dropdown-item v-if="canDelete(row)" command="delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -141,13 +145,14 @@
:edit-data="editData" :edit-data="editData"
:push-from="pushFrom" :push-from="pushFrom"
@success="fetchList" @success="fetchList"
@warehouse-created="loadWarehouses"
/> />
<DocDetailDrawer v-model:visible="detailVisible" :doc-id="detailId" /> <DocDetailDrawer v-model:visible="detailVisible" :doc-id="detailId" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, ArrowDown } from '@element-plus/icons-vue' import { Plus, Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import DocEditor from './DocEditor.vue' import DocEditor from './DocEditor.vue'
@@ -155,6 +160,7 @@ import DocDetailDrawer from './DocDetailDrawer.vue'
import { import {
DOC_NEXT_TYPES, DOC_NEXT_TYPES,
DOC_STATUS, DOC_STATUS,
DOC_STATUS_VALUE,
dateOnly, dateOnly,
docStatusTag, docStatusTag,
docStatusText, docStatusText,
@@ -163,7 +169,6 @@ import {
qty qty
} from './dict' } from './dict'
import { import {
approveDocument,
cancelDocument, cancelDocument,
deleteDocument, deleteDocument,
getDocumentDetail, getDocumentDetail,
@@ -183,9 +188,14 @@ const loading = ref(false)
const rows = ref([]) const rows = ref([])
const warehouses = ref([]) const warehouses = ref([])
const dateRange = ref([]) const dateRange = ref([])
const statusOptions = Object.keys(DOC_STATUS).map((k) => ({ value: k, label: DOC_STATUS[k].label })) /** 状态分类 tab:all 代表「全部」,其余与 DOC_STATUS 一一对应 */
const statusTabs = [
{ value: 'all', label: '全部' },
...Object.keys(DOC_STATUS).map((k) => ({ value: k, label: DOC_STATUS[k].label }))
]
const activeStatus = ref('all')
const filters = reactive({ keyword: '', status: '', doc_type: '', warehouse_id: '' }) const filters = reactive({ keyword: '', doc_type: '', warehouse_id: '' })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 }) const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const editorVisible = ref(false) const editorVisible = ref(false)
@@ -206,16 +216,71 @@ function amountOf(row) {
return 0 return 0
} }
/**
* 列表页的状态与可执行操作对照:审核统一走工作台的审批列表,单据列表不再做审核。
* 草稿只能提交审批、编辑、删除;已驳回可编辑、提交审批、作废、删除;
* 审批中可作废;已生效只读且仅可下推;已作废完全只读。
*/
function canEdit(row) { function canEdit(row) {
return row.status === 0 || row.status === 3 return row.status === DOC_STATUS_VALUE.DRAFT || row.status === DOC_STATUS_VALUE.REJECTED
} }
function canApprove(row) { function canSubmit(row) {
return row.status === 0 || row.status === 3 || row.status === 1 return canEdit(row)
}
function canCancel(row) {
return row.status === DOC_STATUS_VALUE.REJECTED || row.status === DOC_STATUS_VALUE.PENDING
}
function canDelete(row) {
return canEdit(row)
}
/** 只有已生效单据能作为下推来源,作废/草稿/审批中/驳回都不允许 */
function canPush(row) {
return row.status === DOC_STATUS_VALUE.APPROVED
} }
function nextTypes(row) { function nextTypes(row) {
return DOC_NEXT_TYPES[row.doc_type] || [] return canPush(row) ? (DOC_NEXT_TYPES[row.doc_type] || []) : []
}
/** 「更多」里至少有一项时才显示 */
function hasMoreActions(row) {
return canEdit(row) || canCancel(row) || nextTypes(row).length > 0
}
/** 下推项与常规操作之间只加一条分隔线,落在第一个显示的常规操作上 */
function firstExtraCommand(row) {
if (canSubmit(row)) return 'submit'
if (canCancel(row)) return 'cancel'
return ''
}
function needExtraDivider(row, command) {
return nextTypes(row).length > 0 && firstExtraCommand(row) === command
}
function handleMoreCommand(command, row) {
const cmd = String(command)
if (cmd.startsWith('push:')) {
openPush(row, cmd.slice(5))
return
}
// 菜单按状态隐藏后再兜一次底,避免旧数据或绕过菜单触发不该执行的操作
const allowed = {
submit: canSubmit(row),
cancel: canCancel(row),
delete: canDelete(row)
}
if (!allowed[cmd]) {
ElMessage.warning('当前状态不可执行该操作')
return
}
if (cmd === 'submit') handleSubmit(row)
else if (cmd === 'cancel') handleCancel(row)
else if (cmd === 'delete') handleDelete(row)
} }
async function fetchList() { async function fetchList() {
@@ -227,7 +292,7 @@ async function fetchList() {
doc_types: typeOptions.value.join(',') doc_types: typeOptions.value.join(',')
} }
if (filters.keyword) params.keyword = filters.keyword if (filters.keyword) params.keyword = filters.keyword
if (filters.status !== '') params.status = filters.status if (activeStatus.value !== 'all') params.status = activeStatus.value
if (filters.doc_type) params.doc_type = filters.doc_type if (filters.doc_type) params.doc_type = filters.doc_type
if (filters.warehouse_id) params.warehouse_id = filters.warehouse_id if (filters.warehouse_id) params.warehouse_id = filters.warehouse_id
if (dateRange.value && dateRange.value.length === 2) { if (dateRange.value && dateRange.value.length === 2) {
@@ -253,13 +318,19 @@ function handleSearch() {
fetchList() fetchList()
} }
// 切换状态分类即重新查询,页码回到第一页;与关键字、仓库、日期等条件叠加生效
watch(activeStatus, handleSearch)
function resetFilters() { function resetFilters() {
filters.keyword = '' filters.keyword = ''
filters.status = ''
filters.doc_type = '' filters.doc_type = ''
filters.warehouse_id = '' filters.warehouse_id = ''
dateRange.value = [] dateRange.value = []
handleSearch() if (activeStatus.value !== 'all') {
activeStatus.value = 'all' // 触发上面的 watch 刷新
} else {
handleSearch()
}
} }
function openCreate(type) { function openCreate(type) {
@@ -283,6 +354,11 @@ async function openEdit(row) {
} }
function openPush(row, targetType) { function openPush(row, targetType) {
// 二次兜底:即使菜单被绕过,也不能用未生效/已作废单据生成下游单据
if (!canPush(row)) {
ElMessage.warning('仅已生效的单据可以下推')
return
}
editData.value = null editData.value = null
pushFrom.value = { doc: row, type: targetType } pushFrom.value = { doc: row, type: targetType }
editorType.value = targetType editorType.value = targetType
@@ -305,26 +381,6 @@ async function handleSubmit(row) {
fetchList() fetchList()
} }
async function handleApprove(row) {
try {
await ElMessageBox.confirm(
`确认审核「${row.doc_no}」?审核通过后将立即产生库存流水与往来账。`,
'审核确认',
{ type: 'warning' }
)
} catch (e) {
return
}
try {
await approveDocument(row.id)
ElMessage.success('单据已生效')
} catch (e) {
ElMessage.error(e.message || '审核失败')
return
}
fetchList()
}
async function handleCancel(row) { async function handleCancel(row) {
try { try {
await ElMessageBox.confirm(`确认作废「${row.doc_no}」?作废后不可恢复。`, '作废确认', { type: 'warning' }) await ElMessageBox.confirm(`确认作废「${row.doc_no}」?作废后不可恢复。`, '作废确认', { type: 'warning' })
@@ -357,13 +413,18 @@ async function handleDelete(row) {
fetchList() fetchList()
} }
onMounted(async () => { /** 编辑器里快捷新增仓库后,列表页的仓库筛选下拉也要能选到新仓 */
async function loadWarehouses() {
try { try {
const res = await getWarehouseList({ pageSize: 100 }) const res = await getWarehouseList({ pageSize: 100 })
if (res.code === 200) warehouses.value = res.data.list || [] if (res.code === 200) warehouses.value = res.data.list || []
} catch (e) { } catch (e) {
warehouses.value = [] warehouses.value = []
} }
}
onMounted(async () => {
await loadWarehouses()
fetchList() fetchList()
}) })
</script> </script>
@@ -393,6 +454,19 @@ onMounted(async () => {
} }
} }
// 状态分类只作为筛选导航,列表和筛选栏在 tabs 外面,隐藏空的内容区
.status-tabs {
margin-bottom: 12px;
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__content) {
display: none;
}
}
.filter-bar { .filter-bar {
background: var(--el-bg-color); background: var(--el-bg-color);
padding: 16px 16px 0; padding: 16px 16px 0;
@@ -408,6 +482,29 @@ onMounted(async () => {
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
} }
// 操作列统一用 flex 控制间距:el-dropdown 内的按钮拿不到 .el-button + .el-button 的
// 相邻间距,会出现「详情」「下推」贴得太近、箭头却离文字很远、按钮基线不齐的问题
.row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px 10px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.el-dropdown) {
display: inline-flex;
align-items: center;
}
:deep(.el-icon--right) {
margin-left: 2px;
}
}
.doc-no { .doc-no {
font-weight: 500; font-weight: 500;
} }
@@ -0,0 +1,146 @@
<template>
<el-dialog
:model-value="visible"
title="快捷新增客户"
width="480px"
append-to-body
:close-on-click-modal="false"
@update:model-value="close"
@closed="resetForm"
>
<el-alert
title="保存后自动选中新客户,可继续开单"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent>
<el-form-item label="客户名称" prop="customer_name">
<el-input ref="nameInput" v-model="form.customer_name" maxlength="100" placeholder="请输入客户名称" />
</el-form-item>
<el-form-item label="客户类型" prop="customer_type">
<el-select v-model="form.customer_type" style="width: 100%">
<el-option v-for="t in customerTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="联系人" prop="contact_person">
<el-input v-model="form.contact_person" placeholder="选填" />
</el-form-item>
<el-form-item label="联系电话" prop="contact_phone">
<el-input v-model="form.contact_phone" maxlength="20" placeholder="正式客户必填" />
</el-form-item>
<el-form-item label="联系邮箱" prop="contact_email">
<el-input v-model="form.contact_email" placeholder="选填" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" placeholder="选填" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">创建并选中</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createCustomer } from '@/api/customer'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'created'])
const formRef = ref()
const nameInput = ref()
const saving = ref(false)
const customerTypes = [
{ value: '1', label: '企业' },
{ value: '2', label: '政府机构' },
{ value: '3', label: '国企' },
{ value: '4', label: '教育机构' },
{ value: '5', label: '个人' }
]
// status=1 / is_draft=0 才会出现在开单页的客户下拉里(列表按正式启用的客户过滤)
const defaultForm = () => ({
customer_name: '',
customer_type: '1',
contact_person: '',
contact_phone: '',
contact_email: '',
address: '',
customer_level: '3',
status: '1',
is_draft: 0,
credit_limit: 0,
payment_days: 0,
remark: ''
})
const form = reactive(defaultForm())
const rules = {
customer_name: [{ required: true, whitespace: true, message: '请填写客户名称', trigger: 'blur' }],
// 后端正式保存要求联系电话非空,这里提前拦截
contact_phone: [{ required: true, whitespace: true, message: '请填写联系电话', trigger: 'blur' }]
}
watch(() => props.visible, (val) => {
if (!val) return
Object.assign(form, defaultForm())
nextTick(() => {
formRef.value?.clearValidate()
nameInput.value?.focus()
})
})
function close() {
emit('update:visible', false)
}
function resetForm() {
Object.assign(form, defaultForm())
formRef.value?.clearValidate()
}
async function submit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (e) {
return
}
if (!form.customer_name.trim() || !form.contact_phone.trim()) {
ElMessage.warning('请填写客户名称和联系电话')
return
}
saving.value = true
try {
const payload = {
...form,
customer_name: form.customer_name.trim(),
contact_phone: form.contact_phone.trim(),
contact_person: form.contact_person.trim()
}
const res = await createCustomer(payload)
if (!res || res.code !== 200) {
ElMessage.error(res?.msg || '新增客户失败')
return
}
const id = Number(res.data?.id) || 0
ElMessage.success('客户已新增')
emit('created', { ...payload, id })
close()
} catch (e) {
ElMessage.error(e.message || '新增客户失败')
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,137 @@
<template>
<el-dialog
:model-value="visible"
title="快捷新增仓库"
width="480px"
append-to-body
:close-on-click-modal="false"
@update:model-value="close"
@closed="resetForm"
>
<el-alert
title="保存后自动选中新仓库,可继续开单"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent>
<el-form-item label="仓库名称" prop="warehouse_name">
<el-input ref="nameInput" v-model="form.warehouse_name" maxlength="100" placeholder="请输入仓库名称" />
</el-form-item>
<el-form-item label="仓库编号" prop="warehouse_code">
<el-input v-model="form.warehouse_code" placeholder="留空自动生成" />
</el-form-item>
<el-form-item label="负责人" prop="manager">
<el-input v-model="form.manager" placeholder="选填" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" placeholder="选填" />
</el-form-item>
<el-form-item label="默认仓库">
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
<span class="hint">开单时默认带出该仓</span>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">创建并选中</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createWarehouse } from '@/api/erpWarehouse'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'created'])
const formRef = ref()
const nameInput = ref()
const saving = ref(false)
const defaultForm = () => ({
warehouse_name: '',
warehouse_code: '',
manager: '',
phone: '',
address: '',
is_default: 0,
status: 1,
remark: ''
})
const form = reactive(defaultForm())
const rules = {
warehouse_name: [{ required: true, whitespace: true, message: '请填写仓库名称', trigger: 'blur' }]
}
watch(() => props.visible, (val) => {
if (!val) return
Object.assign(form, defaultForm())
nextTick(() => {
formRef.value?.clearValidate()
nameInput.value?.focus()
})
})
function close() {
emit('update:visible', false)
}
function resetForm() {
Object.assign(form, defaultForm())
formRef.value?.clearValidate()
}
async function submit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (e) {
return
}
if (!form.warehouse_name.trim()) {
ElMessage.warning('请填写仓库名称')
return
}
saving.value = true
try {
const payload = {
...form,
warehouse_name: form.warehouse_name.trim(),
warehouse_code: form.warehouse_code.trim()
}
const res = await createWarehouse(payload)
if (!res || res.code !== 200) {
ElMessage.error(res?.msg || '新增仓库失败')
return
}
const id = Number(res.data?.id) || 0
ElMessage.success('仓库已新增')
emit('created', { ...payload, id })
close()
} catch (e) {
ElMessage.error(e.message || '新增仓库失败')
} finally {
saving.value = false
}
}
</script>
<style lang="less" scoped>
.hint {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -32,6 +32,15 @@ export const DOC_STATUS = {
4: { label: '已作废', tag: 'info' } 4: { label: '已作废', tag: 'info' }
} }
/** 单据状态值(与后端 models.ErpDocStatus* 一致),供权限判断使用,避免各处硬编码数字 */
export const DOC_STATUS_VALUE = {
DRAFT: 0,
PENDING: 1,
APPROVED: 2,
REJECTED: 3,
CANCELLED: 4
}
/** 下游单据:从已生效单据「下推」时用的目标类型 */ /** 下游单据:从已生效单据「下推」时用的目标类型 */
export const DOC_NEXT_TYPES = { export const DOC_NEXT_TYPES = {
purchase_order: [ purchase_order: [
@@ -0,0 +1,309 @@
<template>
<div class="approval-page">
<div class="page-header">
<div>
<h2>审批列表</h2>
<p>处理待我审批的进销存单据,审批通过后单据自动生效</p>
</div>
<div class="header-actions">
<el-radio-group v-model="filters.handled" @change="handleScopeChange">
<el-radio-button :value="0">待我审批</el-radio-button>
<el-radio-button :value="1">我已处理</el-radio-button>
</el-radio-group>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
</div>
<div class="approval-layout">
<aside class="type-nav">
<div class="type-nav-title">单据类型</div>
<div
v-for="t in bizTypes"
:key="t.value"
class="type-nav-item"
:class="{ active: filters.biz_type === t.value }"
@click="selectType(t.value)"
>{{ t.label }}</div>
</aside>
<section class="list-content">
<div class="table-card">
<el-table v-loading="loading" :data="list" stripe border size="small">
<el-table-column label="单据标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.instance?.biz_title || '-' }}</template>
</el-table-column>
<el-table-column label="单据类型" width="140">
<template #default="{ row }">{{ bizTypeLabel(row.instance?.biz_type) }}</template>
</el-table-column>
<el-table-column label="发起人" width="120">
<template #default="{ row }">{{ row.instance?.submit_user_name || '-' }}</template>
</el-table-column>
<el-table-column label="审批节点" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.node_name || row.node_key || '-' }}</template>
</el-table-column>
<el-table-column label="单据状态" width="110">
<template #default="{ row }">
<el-tag :type="tagType(row.instance?.status_tag)" size="small">{{ row.instance?.status_label || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务状态" width="110">
<template #default="{ row }">
<el-tag :type="tagType(row.status_tag)" size="small">{{ row.status_label || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到达时间" width="170">
<template #default="{ row }">{{ row.create_time || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDoc(row)">单据详情</el-button>
<el-button link type="primary" size="small" @click="openDetail(row)">
{{ row.status === 0 ? '去处理' : '审批详情' }}
</el-button>
</template>
</el-table-column>
<template #empty><el-empty :description="emptyText" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
</section>
</div>
<!-- 审批前查看单据明细;审批动作与流程时间线复用 OA 审批中心 -->
<DocDetailDrawer v-model:visible="docVisible" :doc-id="docId" />
<ApprovalDetailDrawer v-model="detailVisible" :instance-id="detailId" @changed="loadList" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import DocDetailDrawer from '../document/DocDetailDrawer.vue'
import ApprovalDetailDrawer from '@/views/apps/oa/approvalcenter/components/DetailDrawer.vue'
import { getApprovalTodo } from '@/api/approval'
import { DOC_TYPE_ORDER, docTypeLabel } from '../document/dict'
// 单据审批流的 biz_type 固定为 "erp_" + 单据类型;erp 表示全部进销存单据
const ERP_BIZ_PREFIX = 'erp_'
// tab 按单据类型分类:全部 + 十类进销存单据,value 即运行期 biz_type
const bizTypes = [
{ value: 'erp', label: '全部' },
...DOC_TYPE_ORDER.map((t) => ({ value: ERP_BIZ_PREFIX + t, label: docTypeLabel(t) }))
]
const filters = reactive({ handled: 0, biz_type: 'erp' })
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const list = ref([])
const loading = ref(false)
const detailVisible = ref(false)
const detailId = ref(0)
const docVisible = ref(false)
const docId = ref(0)
const emptyText = computed(() => (filters.handled === 1 ? '暂无已处理的审批' : '暂无待我审批的单据'))
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {}
/** 本列表只认 erp_ 前缀的任务:即使后端旧版本忽略 biz_type 参数,也不会混入 OA/报销等审批 */
function isErpTask(row) {
return String(row?.instance?.biz_type || row?.biz_type || '').startsWith(ERP_BIZ_PREFIX)
}
/** 请求范围强制收敛到进销存,异常值一律回落为「全部 ERP 单据」 */
function erpScope(value) {
return value === 'erp' || String(value || '').startsWith(ERP_BIZ_PREFIX) ? value : 'erp'
}
function bizTypeLabel(value) {
const raw = String(value || '')
return raw.startsWith(ERP_BIZ_PREFIX) ? docTypeLabel(raw.slice(ERP_BIZ_PREFIX.length)) : (raw || '-')
}
async function loadList() {
loading.value = true
try {
const data = responseData(
await getApprovalTodo({
handled: filters.handled,
biz_type: erpScope(filters.biz_type),
page: pagination.page,
pageSize: pagination.pageSize
})
)
// 后端按 biz_type 过滤,前端再兜一层,保证列表与 OA/报销等审批完全隔离
const rows = (data.list || []).filter(isErpTask)
list.value = filters.biz_type === 'erp'
? rows
: rows.filter((row) => String(row.instance?.biz_type || '') === filters.biz_type)
pagination.total = Number(data.total || 0)
} catch (e) {
ElMessage.error(e.message || '加载审批列表失败')
} finally {
loading.value = false
}
}
function handleScopeChange() {
pagination.page = 1
loadList()
}
/** 左侧类型导航:切换单据类型后右侧列表回到第一页重新查询 */
function selectType(value) {
if (filters.biz_type === value) return
filters.biz_type = value
handleScopeChange()
}
function openDetail(row) {
const id = Number(row.instance_id || row.instance?.id || 0)
if (!id) {
ElMessage.warning('未找到审批实例')
return
}
detailId.value = id
detailVisible.value = true
}
function openDoc(row) {
const id = Number(row.instance?.biz_id || 0)
if (!id) {
ElMessage.warning('未找到关联单据')
return
}
docId.value = id
docVisible.value = true
}
function tagType(type) {
if (type === 'error') return 'danger'
return type || 'info'
}
onMounted(loadList)
</script>
<style lang="less" scoped>
.approval-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0 0 6px;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
// 左侧单据类型导航,右侧是对应的审批列表
.approval-layout {
display: flex;
align-items: flex-start;
gap: 16px;
}
.type-nav {
width: 180px;
flex-shrink: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
padding: 8px;
}
.type-nav-title {
padding: 8px 12px 12px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.type-nav-item {
padding: 9px 12px;
margin-bottom: 2px;
border-radius: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
&:hover {
background: var(--el-fill-color-light);
}
&.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 600;
}
}
.list-content {
flex: 1;
min-width: 0;
}
@media (max-width: 900px) {
.approval-layout {
flex-direction: column;
}
.type-nav {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.type-nav-title {
width: 100%;
padding-bottom: 4px;
}
.type-nav-item {
margin-bottom: 0;
}
}
.table-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
padding: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -108,12 +108,17 @@ import { Refresh, Search } from "@element-plus/icons-vue";
import DetailDrawer from "./components/DetailDrawer.vue"; import DetailDrawer from "./components/DetailDrawer.vue";
import { getApprovalTodo } from "@/api/approval"; import { getApprovalTodo } from "@/api/approval";
// 进销存审批由 ERP 工作台的审批列表单独处理,OA 审批中心只管其它审批
const ERP_BIZ_PREFIX = "erp_";
const ALL_NON_ERP = "non_erp";
const bizTypes = [ const bizTypes = [
{ value: ALL_NON_ERP, label: "全部类型" },
{ value: "reimburse", label: "报销单" }, { value: "reimburse", label: "报销单" },
{ value: "oa", label: "OA 通用审批" }, { value: "oa", label: "OA 通用审批" },
]; ];
const filters = reactive({ handled: 0, biz_type: "" }); const filters = reactive({ handled: 0, biz_type: ALL_NON_ERP });
const pagination = reactive({ page: 1, pageSize: 10, total: 0 }); const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const list = ref([]); const list = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -129,19 +134,23 @@ const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const loadList = async () => { const loadList = async () => {
loading.value = true; loading.value = true;
try { try {
// 清空下拉也按「全部非 ERP 审批」处理,保证不会混入进销存审批
const scope = filters.biz_type || ALL_NON_ERP;
const data = responseData( const data = responseData(
await getApprovalTodo({ await getApprovalTodo({
handled: filters.handled, handled: filters.handled,
biz_type: filters.biz_type || "", biz_type: scope,
page: pagination.page, page: pagination.page,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
}), }),
); );
// 前端二次过滤:类型下拉切换后即时生效 // 后端旧版本忽略 biz_type 时再兜一层,隔离始终生效
const rows = data.list || []; const rows = (data.list || []).filter(
list.value = filters.biz_type (row) => !String(row?.instance?.biz_type || "").startsWith(ERP_BIZ_PREFIX)
? rows.filter((row) => row.instance?.biz_type === filters.biz_type) );
: rows; list.value = scope === ALL_NON_ERP
? rows
: rows.filter((row) => row.instance?.biz_type === scope);
pagination.total = Number(data.total || 0); pagination.total = Number(data.total || 0);
} catch (error) { } catch (error) {
ElMessage.error(error?.message || "加载审批列表失败"); ElMessage.error(error?.message || "加载审批列表失败");
+6
View File
@@ -482,6 +482,12 @@ func (c *BackendApprovalController) Options() {
"code": m.Code, "name": m.Name, "enabled": enabled, "code": m.Code, "name": m.Name, "enabled": enabled,
}) })
} }
// 进销存单据运行期按 biz_type = "erp_" + doc_type 找流程。这里额外开放「采购订单审批」
// 分类,让用户能配出只作用于采购订单的流程;不配时仍会回落到模块级 erp 流程。
// 分类 code 即 biz_type,与 FlowSave 的「分类与单据类型对齐」约定一致。
moduleList = append(moduleList, map[string]interface{}{
"code": models.ApprovalBizPurchaseOrder, "name": "采购订单审批", "enabled": true,
})
// 租户用户 // 租户用户
var tu []models.SystemTenantUser var tu []models.SystemTenantUser
@@ -142,6 +142,25 @@ func (c *BackendApprovalRuntimeController) InstanceTodo() {
} else { } else {
qs = qs.Filter("status", models.ApprovalTaskPending) qs = qs.Filter("status", models.ApprovalTaskPending)
} }
// 审批任务自带 biz_type,直接在任务查询上过滤,total 与分页才准确。
// erp = 只查进销存单据;non_erp = 只查其它审批,两个审批列表互不混入。
if bizType := strings.TrimSpace(c.GetString("biz_type")); bizType != "" {
switch bizType {
case "erp", "erp_":
qs = qs.Filter("biz_type__startswith", "erp_")
case "non_erp":
// 用已知单据类型精确排除,避免依赖 Exclude 对前缀操作符的支持
erpBizTypes := make([]string, 0, len(erpDocTypes))
for docType := range erpDocTypes {
erpBizTypes = append(erpBizTypes, models.ErpDocApprovalBizType(docType))
}
if len(erpBizTypes) > 0 {
qs = qs.Exclude("biz_type__in", erpBizTypes)
}
default:
qs = qs.Filter("biz_type", bizType)
}
}
total, _ := qs.Count() total, _ := qs.Count()
var tasks []models.ApprovalTask var tasks []models.ApprovalTask
@@ -163,6 +182,7 @@ func (c *BackendApprovalRuntimeController) InstanceTodo() {
continue continue
} }
taskView := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey]) taskView := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey])
taskView["instance_id"] = t.InstanceID
taskView["instance"] = oaApprovalInstanceView(inst) taskView["instance"] = oaApprovalInstanceView(inst)
list = append(list, taskView) list = append(list, taskView)
} }
+5
View File
@@ -578,6 +578,11 @@ func erpDocExecutableCore(a erpActor, id uint64) (map[string]interface{}, int, s
if e != nil { if e != nil {
return nil, 404, "单据未找到" return nil, 404, "单据未找到"
} }
// 只有已生效单据可作为下推来源;作废、草稿、审批中、驳回都直接拒绝,
// 与保存时 erpValidateDocHead 的来源状态校验保持一致,避免绕过前端菜单
if doc.Status != models.ErpDocStatusApproved {
return nil, 400, "来源单据未生效,不能下推"
}
isReturn := doc.DocType == models.ErpDocPurchaseReturn || doc.DocType == models.ErpDocSalesReturn isReturn := doc.DocType == models.ErpDocPurchaseReturn || doc.DocType == models.ErpDocSalesReturn
items := erpDocItems(models.Orm, doc.ID) items := erpDocItems(models.Orm, doc.ID)
out := make([]map[string]interface{}, 0, len(items)) out := make([]map[string]interface{}, 0, len(items))
+13 -3
View File
@@ -5,7 +5,7 @@
-- ============================================================ -- ============================================================
-- 目标结构(「大类」= 模块根下一层目录,会作为移动端功能页的分组标题; -- 目标结构(「大类」= 模块根下一层目录,会作为移动端功能页的分组标题;
-- 「小类」= 叶子菜单,渲染为功能卡片): -- 「小类」= 叶子菜单,渲染为功能卡片):
-- 工作台 -> 工作台(复用已有仪表盘页面 /apps/erp/dashboard) -- 工作台(目录)-> 工作台(仪表盘 /apps/erp/dashboard)/ 审批列表(/apps/erp/workbench/approval)
-- 基础资料 -> 商品 / 供应商 / 客户 / 仓库 -- 基础资料 -> 商品 / 供应商 / 客户 / 仓库
-- 采购管理 -> 采购订单 / 采购入库 / 采购退货 -- 采购管理 -> 采购订单 / 采购入库 / 采购退货
-- 销售管理 -> 销售订单 / 销售出库 / 销售退货 -- 销售管理 -> 销售订单 / 销售出库 / 销售退货
@@ -36,6 +36,12 @@ FROM DUAL
WHERE @erp_id IS NOT NULL WHERE @erp_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/erp/workbench' AND delete_time IS NULL) x); AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/erp/workbench' AND delete_time IS NULL) x);
-- 工作台必须是目录:历史数据里可能被建成了页面,统一纠正,目录内才放工作台/审批列表两个页面
UPDATE yz_system_menu
SET type = 1, component_path = NULL, update_time = NOW()
WHERE delete_time IS NULL AND path = '/apps/erp/workbench'
AND (type <> 1 OR (component_path IS NOT NULL AND component_path <> ''));
INSERT INTO yz_system_menu INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark) (pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT @erp_id, '基础资料', '/apps/erp/basic', NULL, 'fa-solid fa-database', 2, 1, 1, '[2]', 1, 'system', '进销存大类:基础资料' SELECT @erp_id, '基础资料', '/apps/erp/basic', NULL, 'fa-solid fa-database', 2, 1, 1, '[2]', 1, 'system', '进销存大类:基础资料'
@@ -91,9 +97,9 @@ SET @erp_basic := (SELECT id FROM yz_system_menu WHERE path = '/apps/erp/bas
SET @erp_system := (SELECT id FROM yz_system_menu WHERE path = '/apps/erp/system' AND delete_time IS NULL LIMIT 1); SET @erp_system := (SELECT id FROM yz_system_menu WHERE path = '/apps/erp/system' AND delete_time IS NULL LIMIT 1);
-- ---------- 2. 已有菜单迁入新树(避免同一功能出现两份) ---------- -- ---------- 2. 已有菜单迁入新树(避免同一功能出现两份) ----------
-- 仪表盘 -> 工作台大类,并更名为「工作台」;路由与页面文件不变 -- 仪表盘 -> 工作台目录内的「工作台」页面;路由与页面文件不变
UPDATE yz_system_menu UPDATE yz_system_menu
SET pid = @erp_workbench, title = '工作台', sort = 1, update_time = NOW() SET pid = @erp_workbench, title = '工作台', sort = 1, type = 2, update_time = NOW()
WHERE delete_time IS NULL AND path = '/apps/erp/dashboard' AND @erp_workbench IS NOT NULL; WHERE delete_time IS NULL AND path = '/apps/erp/dashboard' AND @erp_workbench IS NOT NULL;
-- 供应商列表 / 客户列表 -> 基础资料大类,去掉「列表」后缀;路由与页面文件不变 -- 供应商列表 / 客户列表 -> 基础资料大类,去掉「列表」后缀;路由与页面文件不变
@@ -127,6 +133,8 @@ CREATE TEMPORARY TABLE tmp_erp_leaf (
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
INSERT INTO tmp_erp_leaf (parent_path, title, path, icon, sort, remark) VALUES INSERT INTO tmp_erp_leaf (parent_path, title, path, icon, sort, remark) VALUES
('/apps/erp/workbench', '工作台', '/apps/erp/dashboard', 'fa-solid fa-gauge', 1, '进销存工作台(仪表盘)'),
('/apps/erp/workbench', '审批列表', '/apps/erp/workbench/approval', 'fa-solid fa-clipboard-check', 2, '待我审批 / 我已处理,审批后单据自动生效'),
('/apps/erp/basic', '商品', '/apps/erp/basic/product', 'fa-solid fa-box', 1, '商品档案'), ('/apps/erp/basic', '商品', '/apps/erp/basic/product', 'fa-solid fa-box', 1, '商品档案'),
('/apps/erp/basic', '仓库', '/apps/erp/basic/warehouse', 'fa-solid fa-boxes-packing', 4, '仓库档案'), ('/apps/erp/basic', '仓库', '/apps/erp/basic/warehouse', 'fa-solid fa-boxes-packing', 4, '仓库档案'),
('/apps/erp/purchase', '采购订单', '/apps/erp/purchase/order', 'fa-solid fa-file-invoice', 1, '采购订单'), ('/apps/erp/purchase', '采购订单', '/apps/erp/purchase/order', 'fa-solid fa-file-invoice', 1, '采购订单'),
@@ -168,6 +176,8 @@ CREATE TEMPORARY TABLE tmp_erp_page (
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
INSERT INTO tmp_erp_page (path, component_path) VALUES INSERT INTO tmp_erp_page (path, component_path) VALUES
('/apps/erp/dashboard', '/apps/erp/dashboard/index.vue'),
('/apps/erp/workbench/approval', '/apps/erp/workbench/approvalList.vue'),
('/apps/erp/basic/product', '/apps/erp/basic/product.vue'), ('/apps/erp/basic/product', '/apps/erp/basic/product.vue'),
('/apps/erp/basic/warehouse', '/apps/erp/basic/warehouse.vue'), ('/apps/erp/basic/warehouse', '/apps/erp/basic/warehouse.vue'),
('/apps/erp/purchase/order', '/apps/erp/purchase/order.vue'), ('/apps/erp/purchase/order', '/apps/erp/purchase/order.vue'),
+4 -3
View File
@@ -44,9 +44,10 @@ const (
// 单据类型 // 单据类型
const ( const (
ApprovalBizReimburse = "reimburse" // OA 报销单 ApprovalBizReimburse = "reimburse" // OA 报销单
ApprovalBizBid = "bid" // CRM 投标立项 ApprovalBizBid = "bid" // CRM 投标立项
ApprovalBizDomain = "domain" // 域名申请(平台侧审批) ApprovalBizDomain = "domain" // 域名申请(平台侧审批)
ApprovalBizPurchaseOrder = "erp_purchase_order" // 采购订单审批(与 ErpDocApprovalBizType 一致)
) )
// ---------------- 租户侧配置 ---------------- // ---------------- 租户侧配置 ----------------