diff --git a/backend/src/composables/useAdminNavigation.js b/backend/src/composables/useAdminNavigation.js index 2b54e7f..4389e7e 100644 --- a/backend/src/composables/useAdminNavigation.js +++ b/backend/src/composables/useAdminNavigation.js @@ -123,6 +123,89 @@ function mergeFixedCmsMenu(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) { (menus || []).forEach((menu) => { if (menu.children && menu.children.length > 0) { @@ -260,7 +343,9 @@ export function useAdminNavigation() { const menuStore = useMenuStore(); const list = computed(() => - sortMenusRecursively(processMenus(mergeFixedCmsMenu(menuStore.menus || []))), + sortMenusRecursively( + processMenus(normalizeErpWorkbench(mergeFixedCmsMenu(menuStore.menus || []))) + ), ); const currentModule = computed(() => { diff --git a/backend/src/router/index.js b/backend/src/router/index.js index a732133..51820cf 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -22,6 +22,19 @@ const staticMainChildren = [ component: () => import("@/views/system/approval/index.vue"), 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 直达) { path: "/apps/oa/approvalcenter/mine", @@ -263,10 +276,14 @@ export async function loadAndAddDynamicRoutes() { return Promise.resolve(); } } catch (error) { - console.error('加载动态路由失败:', error); + // 菜单接口异常时不要把错误抛给导航守卫,否则应用会卡在白屏; + // 保留内置静态路由(工作台、审批列表、审批流配置等)继续可用。 + // 不重置 dynamicRoutesAdded,避免每次路由切换都重试失败的接口, + // 刷新页面或手动刷新菜单即可重新拉取。 + console.error('加载动态路由失败,已保留内置路由:', error); dynamicRoutesAdded = true; routesLoadingPromise = null; - throw error; + return Promise.resolve(); } })(); diff --git a/backend/src/stores/menu.js b/backend/src/stores/menu.js index 73aa347..3aa9895 100644 --- a/backend/src/stores/menu.js +++ b/backend/src/stores/menu.js @@ -132,9 +132,11 @@ export const useMenuStore = defineStore('menu', () => { // 用户登录,使用 getMenus 接口 res = await getMenus(userInfo.id); - // 检查响应格式 + // 检查响应格式:HTTP 200 但 body 为空(后端/网关异常)会走到这里, + // 打印原始值便于区分「服务挂了」和「返回了空内容」 if (!res) { - throw new Error('获取菜单失败:服务器无响应'); + console.error('菜单接口返回空响应:', { res, url: `/backend/menu/${userInfo.id}` }); + throw new Error('获取菜单失败:接口返回空响应(后端服务或网关异常)'); } // 检查后端返回的 code 字段 @@ -215,8 +217,9 @@ export const useMenuStore = defineStore('menu', () => { }; // 刷新菜单(强制从 API 获取) + // 不预先清缓存:接口异常时 fetchMenus 还能回退到上一份菜单,避免整个应用没菜单可用; + // 请求成功后 saveToCache 会直接覆盖旧缓存。 const refreshMenus = async () => { - clearCache(); return await fetchMenus(true); }; diff --git a/backend/src/views/apps/erp/dashboard/index.vue b/backend/src/views/apps/erp/dashboard/index.vue index 8143520..9822923 100644 --- a/backend/src/views/apps/erp/dashboard/index.vue +++ b/backend/src/views/apps/erp/dashboard/index.vue @@ -1,5 +1,10 @@ - + @@ -141,13 +145,14 @@ :edit-data="editData" :push-from="pushFrom" @success="fetchList" + @warehouse-created="loadWarehouses" /> @@ -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 { background: var(--el-bg-color); padding: 16px 16px 0; @@ -408,6 +482,29 @@ onMounted(async () => { 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 { font-weight: 500; } diff --git a/backend/src/views/apps/erp/document/QuickCreateCustomer.vue b/backend/src/views/apps/erp/document/QuickCreateCustomer.vue new file mode 100644 index 0000000..94894b6 --- /dev/null +++ b/backend/src/views/apps/erp/document/QuickCreateCustomer.vue @@ -0,0 +1,146 @@ + + + diff --git a/backend/src/views/apps/erp/document/QuickCreateWarehouse.vue b/backend/src/views/apps/erp/document/QuickCreateWarehouse.vue new file mode 100644 index 0000000..1f9765d --- /dev/null +++ b/backend/src/views/apps/erp/document/QuickCreateWarehouse.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/backend/src/views/apps/erp/document/dict.js b/backend/src/views/apps/erp/document/dict.js index a03d409..bcf776f 100644 --- a/backend/src/views/apps/erp/document/dict.js +++ b/backend/src/views/apps/erp/document/dict.js @@ -32,6 +32,15 @@ export const DOC_STATUS = { 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 = { purchase_order: [ diff --git a/backend/src/views/apps/erp/workbench/approvalList.vue b/backend/src/views/apps/erp/workbench/approvalList.vue new file mode 100644 index 0000000..3c81eeb --- /dev/null +++ b/backend/src/views/apps/erp/workbench/approvalList.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/backend/src/views/apps/oa/approvalcenter/list.vue b/backend/src/views/apps/oa/approvalcenter/list.vue index 241e968..c991314 100644 --- a/backend/src/views/apps/oa/approvalcenter/list.vue +++ b/backend/src/views/apps/oa/approvalcenter/list.vue @@ -108,12 +108,17 @@ import { Refresh, Search } from "@element-plus/icons-vue"; import DetailDrawer from "./components/DetailDrawer.vue"; import { getApprovalTodo } from "@/api/approval"; +// 进销存审批由 ERP 工作台的审批列表单独处理,OA 审批中心只管其它审批 +const ERP_BIZ_PREFIX = "erp_"; +const ALL_NON_ERP = "non_erp"; + const bizTypes = [ + { value: ALL_NON_ERP, label: "全部类型" }, { value: "reimburse", label: "报销单" }, { 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 list = ref([]); const loading = ref(false); @@ -129,19 +134,23 @@ const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {}; const loadList = async () => { loading.value = true; try { + // 清空下拉也按「全部非 ERP 审批」处理,保证不会混入进销存审批 + const scope = filters.biz_type || ALL_NON_ERP; const data = responseData( await getApprovalTodo({ handled: filters.handled, - biz_type: filters.biz_type || "", + biz_type: scope, page: pagination.page, pageSize: pagination.pageSize, }), ); - // 前端二次过滤:类型下拉切换后即时生效 - const rows = data.list || []; - list.value = filters.biz_type - ? rows.filter((row) => row.instance?.biz_type === filters.biz_type) - : rows; + // 后端旧版本忽略 biz_type 时再兜一层,隔离始终生效 + const rows = (data.list || []).filter( + (row) => !String(row?.instance?.biz_type || "").startsWith(ERP_BIZ_PREFIX) + ); + list.value = scope === ALL_NON_ERP + ? rows + : rows.filter((row) => row.instance?.biz_type === scope); pagination.total = Number(data.total || 0); } catch (error) { ElMessage.error(error?.message || "加载审批列表失败"); diff --git a/go/controllers/backend_approval.go b/go/controllers/backend_approval.go index 49b64df..e1dc296 100644 --- a/go/controllers/backend_approval.go +++ b/go/controllers/backend_approval.go @@ -482,6 +482,12 @@ func (c *BackendApprovalController) Options() { "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 diff --git a/go/controllers/backend_approval_runtime.go b/go/controllers/backend_approval_runtime.go index 5f074e6..3ac9c91 100644 --- a/go/controllers/backend_approval_runtime.go +++ b/go/controllers/backend_approval_runtime.go @@ -142,6 +142,25 @@ func (c *BackendApprovalRuntimeController) InstanceTodo() { } else { 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() var tasks []models.ApprovalTask @@ -163,6 +182,7 @@ func (c *BackendApprovalRuntimeController) InstanceTodo() { continue } taskView := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey]) + taskView["instance_id"] = t.InstanceID taskView["instance"] = oaApprovalInstanceView(inst) list = append(list, taskView) } diff --git a/go/controllers/erp_document_core.go b/go/controllers/erp_document_core.go index 0672163..04a8353 100644 --- a/go/controllers/erp_document_core.go +++ b/go/controllers/erp_document_core.go @@ -578,6 +578,11 @@ func erpDocExecutableCore(a erpActor, id uint64) (map[string]interface{}, int, s if e != nil { return nil, 404, "单据未找到" } + // 只有已生效单据可作为下推来源;作废、草稿、审批中、驳回都直接拒绝, + // 与保存时 erpValidateDocHead 的来源状态校验保持一致,避免绕过前端菜单 + if doc.Status != models.ErpDocStatusApproved { + return nil, 400, "来源单据未生效,不能下推" + } isReturn := doc.DocType == models.ErpDocPurchaseReturn || doc.DocType == models.ErpDocSalesReturn items := erpDocItems(models.Orm, doc.ID) out := make([]map[string]interface{}, 0, len(items)) diff --git a/go/docs/sql/erp_menus.sql b/go/docs/sql/erp_menus.sql index 0797758..34f6f8a 100644 --- a/go/docs/sql/erp_menus.sql +++ b/go/docs/sql/erp_menus.sql @@ -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 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 (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', '进销存大类:基础资料' @@ -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); -- ---------- 2. 已有菜单迁入新树(避免同一功能出现两份) ---------- --- 仪表盘 -> 工作台大类,并更名为「工作台」;路由与页面文件不变 +-- 仪表盘 -> 工作台目录内的「工作台」页面;路由与页面文件不变 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; -- 供应商列表 / 客户列表 -> 基础资料大类,去掉「列表」后缀;路由与页面文件不变 @@ -127,6 +133,8 @@ CREATE TEMPORARY TABLE tmp_erp_leaf ( ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; 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/warehouse', 'fa-solid fa-boxes-packing', 4, '仓库档案'), ('/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; 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/warehouse', '/apps/erp/basic/warehouse.vue'), ('/apps/erp/purchase/order', '/apps/erp/purchase/order.vue'), diff --git a/go/models/approval.go b/go/models/approval.go index 73dd875..f7b77d9 100644 --- a/go/models/approval.go +++ b/go/models/approval.go @@ -44,9 +44,10 @@ const ( // 单据类型 const ( - ApprovalBizReimburse = "reimburse" // OA 报销单 - ApprovalBizBid = "bid" // CRM 投标立项 - ApprovalBizDomain = "domain" // 域名申请(平台侧审批) + ApprovalBizReimburse = "reimburse" // OA 报销单 + ApprovalBizBid = "bid" // CRM 投标立项 + ApprovalBizDomain = "domain" // 域名申请(平台侧审批) + ApprovalBizPurchaseOrder = "erp_purchase_order" // 采购订单审批(与 ErpDocApprovalBizType 一致) ) // ---------------- 租户侧配置 ----------------