更新
This commit is contained in:
1 parent
4ab63e77ed
commit
eb9b7beff5
16 files changed
+1010
-70
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<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-col :span="4" :xs="12" v-for="(item, index) in firstRowStats" :key="'first-' + index">
|
||||
@@ -58,6 +63,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import * as echarts from 'echarts';
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
@@ -66,6 +72,8 @@ import { getErpDashboard } from '@/api/erpReport';
|
||||
type Stat = { label: string; text: string; prefix?: string };
|
||||
|
||||
const loading = ref(false);
|
||||
const router = useRouter();
|
||||
const goApproval = () => router.push('/apps/erp/workbench/approval');
|
||||
|
||||
// 统计卡片数据 - 第一行(金额)
|
||||
const firstRowStats = ref<Stat[]>([]);
|
||||
@@ -255,6 +263,16 @@ onUnmounted(() => {
|
||||
// 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 {
|
||||
.el-col {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -29,9 +29,14 @@
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<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-select v-model="form.warehouse_id" placeholder="请选择仓库" class="field-select" @change="onWarehouseChange">
|
||||
<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-col>
|
||||
|
||||
@@ -50,16 +55,21 @@
|
||||
|
||||
<el-col v-if="partnerType" :span="8">
|
||||
<el-form-item :label="partnerType === 'supplier' ? '供应商' : '客户'" required>
|
||||
<div class="field-with-action">
|
||||
<el-select
|
||||
v-model="form.partner_id"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索名称选择"
|
||||
style="width: 100%"
|
||||
class="field-select"
|
||||
@change="onPartnerChange"
|
||||
>
|
||||
<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-col>
|
||||
|
||||
@@ -199,12 +209,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickCreateWarehouse v-model:visible="quickWarehouseVisible" @created="onWarehouseCreated" />
|
||||
<QuickCreateCustomer v-model:visible="quickCustomerVisible" @created="onCustomerCreated" />
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="close">取消</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 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>
|
||||
</template>
|
||||
</el-drawer>
|
||||
@@ -215,7 +229,10 @@ import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Download, DocumentCopy } from '@element-plus/icons-vue'
|
||||
import ProductSelect from './ProductSelect.vue'
|
||||
import QuickCreateWarehouse from './QuickCreateWarehouse.vue'
|
||||
import QuickCreateCustomer from './QuickCreateCustomer.vue'
|
||||
import {
|
||||
DOC_STATUS_VALUE,
|
||||
dateOnly,
|
||||
docTypeLabel,
|
||||
docTypePartner,
|
||||
@@ -241,10 +258,12 @@ const props = defineProps({
|
||||
/** 预填明细(补货建议转采购单用):[{ product_id, product_name, spec, unit, quantity, unit_price }] */
|
||||
presetItems: { type: Array, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
const emit = defineEmits(['update:visible', 'success', 'warehouse-created'])
|
||||
|
||||
let rowSeq = 0
|
||||
const warehouses = ref([])
|
||||
const quickWarehouseVisible = ref(false)
|
||||
const quickCustomerVisible = ref(false)
|
||||
const partners = ref([])
|
||||
const sourceDocs = ref([])
|
||||
const flows = ref([])
|
||||
@@ -261,7 +280,7 @@ const form = reactive({
|
||||
warehouse_id: 0,
|
||||
to_warehouse_id: 0,
|
||||
partner_type: '',
|
||||
partner_id: 0,
|
||||
partner_id: '',
|
||||
partner_name: '',
|
||||
source_doc_id: 0,
|
||||
discount_amount: 0,
|
||||
@@ -332,7 +351,8 @@ async function loadPartners() {
|
||||
|
||||
async function loadSourceDocs() {
|
||||
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 || []
|
||||
} catch (e) {
|
||||
sourceDocs.value = []
|
||||
@@ -357,7 +377,7 @@ function resetForm() {
|
||||
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.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.source_doc_id = src ? Number(src.source_doc_id) || 0 : 0
|
||||
form.discount_amount = src ? Number(src.discount_amount) || 0 : 0
|
||||
@@ -433,7 +453,7 @@ async function syncBatchFlags(rows) {
|
||||
function applyPushFrom(pf) {
|
||||
const src = pf.doc || {}
|
||||
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.warehouse_id = Number(src.warehouse_id) || form.warehouse_id
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
const p = partners.value.find((x) => String(x.id) === String(id))
|
||||
form.partner_name = p ? partnerLabel(p) : ''
|
||||
@@ -608,7 +661,7 @@ function buildPayload() {
|
||||
warehouse_id: form.warehouse_id,
|
||||
to_warehouse_id: form.to_warehouse_id,
|
||||
partner_type: partnerType.value,
|
||||
partner_id: form.partner_id,
|
||||
partner_id: Number(form.partner_id) || 0,
|
||||
source_doc_id: form.source_doc_id,
|
||||
// 调拨/盘点等不涉及成交价的单据不允许有折扣,否则会被后端计入金额
|
||||
discount_amount: showPrice.value ? form.discount_amount : 0,
|
||||
@@ -663,6 +716,8 @@ async function save(mode) {
|
||||
}
|
||||
|
||||
function close() {
|
||||
quickWarehouseVisible.value = false
|
||||
quickCustomerVisible.value = false
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
@@ -676,6 +731,19 @@ function close() {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
// 仓库、客户等下拉右侧挂「+」快捷新增按钮时共用
|
||||
.field-with-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.field-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.credit-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
</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">
|
||||
<el-form :inline="true" @submit.prevent>
|
||||
<el-form-item v-if="typeOptions.length > 1" label="单据类型">
|
||||
@@ -37,11 +41,6 @@
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</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-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" />
|
||||
@@ -102,22 +101,27 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 }">
|
||||
<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="warning" size="small" @click="handleSubmit(row)">提交审批</el-button>
|
||||
<el-button v-if="canApprove(row)" link type="success" size="small" @click="handleApprove(row)">审核</el-button>
|
||||
<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" @click="openDetail(row)">详情</el-button>
|
||||
<el-dropdown v-if="hasMoreActions(row)" trigger="click" @command="(cmd) => handleMoreCommand(cmd, row)">
|
||||
<el-button link type="primary" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="n in nextTypes(row)" :key="n.type" :command="n.type">{{ n.label }}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-for="n in nextTypes(row)"
|
||||
:key="n.type"
|
||||
:command="'push:' + n.type"
|
||||
>下推:{{ n.label }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canSubmit(row)" command="submit" :divided="needExtraDivider(row, 'submit')">提交审批</el-dropdown-item>
|
||||
<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>
|
||||
<el-button v-if="row.status !== 2 && row.status !== 4" link type="danger" size="small" @click="handleCancel(row)">作废</el-button>
|
||||
<el-button v-if="canEdit(row)" link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -141,13 +145,14 @@
|
||||
:edit-data="editData"
|
||||
:push-from="pushFrom"
|
||||
@success="fetchList"
|
||||
@warehouse-created="loadWarehouses"
|
||||
/>
|
||||
<DocDetailDrawer v-model:visible="detailVisible" :doc-id="detailId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
||||
import DocEditor from './DocEditor.vue'
|
||||
@@ -155,6 +160,7 @@ import DocDetailDrawer from './DocDetailDrawer.vue'
|
||||
import {
|
||||
DOC_NEXT_TYPES,
|
||||
DOC_STATUS,
|
||||
DOC_STATUS_VALUE,
|
||||
dateOnly,
|
||||
docStatusTag,
|
||||
docStatusText,
|
||||
@@ -163,7 +169,6 @@ import {
|
||||
qty
|
||||
} from './dict'
|
||||
import {
|
||||
approveDocument,
|
||||
cancelDocument,
|
||||
deleteDocument,
|
||||
getDocumentDetail,
|
||||
@@ -183,9 +188,14 @@ const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const warehouses = 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 editorVisible = ref(false)
|
||||
@@ -206,16 +216,71 @@ function amountOf(row) {
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页的状态与可执行操作对照:审核统一走工作台的审批列表,单据列表不再做审核。
|
||||
* 草稿只能提交审批、编辑、删除;已驳回可编辑、提交审批、作废、删除;
|
||||
* 审批中可作废;已生效只读且仅可下推;已作废完全只读。
|
||||
*/
|
||||
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) {
|
||||
return row.status === 0 || row.status === 3 || row.status === 1
|
||||
function canSubmit(row) {
|
||||
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) {
|
||||
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() {
|
||||
@@ -227,7 +292,7 @@ async function fetchList() {
|
||||
doc_types: typeOptions.value.join(',')
|
||||
}
|
||||
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.warehouse_id) params.warehouse_id = filters.warehouse_id
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
@@ -253,13 +318,19 @@ function handleSearch() {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 切换状态分类即重新查询,页码回到第一页;与关键字、仓库、日期等条件叠加生效
|
||||
watch(activeStatus, handleSearch)
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
filters.doc_type = ''
|
||||
filters.warehouse_id = ''
|
||||
dateRange.value = []
|
||||
if (activeStatus.value !== 'all') {
|
||||
activeStatus.value = 'all' // 触发上面的 watch 刷新
|
||||
} else {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(type) {
|
||||
@@ -283,6 +354,11 @@ async function openEdit(row) {
|
||||
}
|
||||
|
||||
function openPush(row, targetType) {
|
||||
// 二次兜底:即使菜单被绕过,也不能用未生效/已作废单据生成下游单据
|
||||
if (!canPush(row)) {
|
||||
ElMessage.warning('仅已生效的单据可以下推')
|
||||
return
|
||||
}
|
||||
editData.value = null
|
||||
pushFrom.value = { doc: row, type: targetType }
|
||||
editorType.value = targetType
|
||||
@@ -305,26 +381,6 @@ async function handleSubmit(row) {
|
||||
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) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认作废「${row.doc_no}」?作废后不可恢复。`, '作废确认', { type: 'warning' })
|
||||
@@ -357,13 +413,18 @@ async function handleDelete(row) {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
/** 编辑器里快捷新增仓库后,列表页的仓库筛选下拉也要能选到新仓 */
|
||||
async function loadWarehouses() {
|
||||
try {
|
||||
const res = await getWarehouseList({ pageSize: 100 })
|
||||
if (res.code === 200) warehouses.value = res.data.list || []
|
||||
} catch (e) {
|
||||
warehouses.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadWarehouses()
|
||||
fetchList()
|
||||
})
|
||||
</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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
/** 单据状态值(与后端 models.ErpDocStatus* 一致),供权限判断使用,避免各处硬编码数字 */
|
||||
export const DOC_STATUS_VALUE = {
|
||||
DRAFT: 0,
|
||||
PENDING: 1,
|
||||
APPROVED: 2,
|
||||
REJECTED: 3,
|
||||
CANCELLED: 4
|
||||
}
|
||||
|
||||
/** 下游单据:从已生效单据「下推」时用的目标类型 */
|
||||
export const DOC_NEXT_TYPES = {
|
||||
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 { 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 || "加载审批列表失败");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -47,6 +47,7 @@ const (
|
||||
ApprovalBizReimburse = "reimburse" // OA 报销单
|
||||
ApprovalBizBid = "bid" // CRM 投标立项
|
||||
ApprovalBizDomain = "domain" // 域名申请(平台侧审批)
|
||||
ApprovalBizPurchaseOrder = "erp_purchase_order" // 采购订单审批(与 ErpDocApprovalBizType 一致)
|
||||
)
|
||||
|
||||
// ---------------- 租户侧配置 ----------------
|
||||
|
||||
Reference in new issue
Block a user