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];
}
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(() => {
+19 -2
View File
@@ -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();
}
})();
+6 -3
View File
@@ -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">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
<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>
<el-select
v-model="form.partner_id"
filterable
clearable
placeholder="搜索名称选择"
style="width: 100%"
@change="onPartnerChange"
>
<el-option v-for="p in partners" :key="p.id" :label="partnerLabel(p)" :value="p.id" />
</el-select>
<div class="field-with-action">
<el-select
v-model="form.partner_id"
filterable
clearable
placeholder="搜索名称选择"
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>
<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>
<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-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 class="row-actions">
<el-button v-if="canEdit(row)" link type="primary" size="small" @click="openEdit(row)">编辑</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="'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>
</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 = []
handleSearch()
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 || "加载审批列表失败");