1255 lines
38 KiB
Vue
1255 lines
38 KiB
Vue
<template>
|
||
<RouterView v-if="isAuthLayout" />
|
||
|
||
<div v-else :class="shellClass">
|
||
<aside class="sidebar">
|
||
<div class="brand">
|
||
<img class="brand-logo" src="/logo-mark.png" alt="宁波百华智能科技有限公司 logo" />
|
||
<div>
|
||
<h1>百华智能</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="nav-list nav-stage-list">
|
||
<div
|
||
v-for="stage in filteredNavStages"
|
||
:key="stage.key"
|
||
class="nav-stage-group"
|
||
:class="{
|
||
'nav-stage-group-active': isStageActive(stage),
|
||
'nav-stage-group-expanded': isStageExpanded(stage)
|
||
}"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="nav-stage"
|
||
:class="{ 'nav-stage-active': isStageActive(stage), 'nav-stage-has-children': visibleStageChildren(stage).length }"
|
||
:title="`${stage.step} ${stage.label} - ${stage.desc}`"
|
||
@click="handleStageClick(stage)"
|
||
>
|
||
<span class="nav-stage-step" aria-hidden="true">
|
||
<AppIcon :name="stage.icon" />
|
||
</span>
|
||
<div class="nav-stage-body">
|
||
<strong>{{ stage.label }}</strong>
|
||
</div>
|
||
<span v-if="visibleStageChildren(stage).length" class="nav-stage-chevron" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" role="img">
|
||
<path d="M8 9L12 13L16 9" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
|
||
<div v-if="visibleStageChildren(stage).length && isStageExpanded(stage) && !sidebarCollapsed" class="nav-submenu">
|
||
<RouterLink
|
||
v-for="child in visibleStageChildren(stage)"
|
||
:key="child.key"
|
||
:to="child.to"
|
||
class="nav-subitem"
|
||
:class="{ 'nav-subitem-active': isChildActive(child) }"
|
||
>
|
||
<span class="nav-subitem-icon" aria-hidden="true">
|
||
<AppIcon :name="child.icon || stage.icon" />
|
||
</span>
|
||
<span>{{ child.label }}</span>
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="status-chip">
|
||
<span class="status-dot"></span>
|
||
采购、百华仓库、生产、发货、财务、售后、系统拓展已接入真实业务流
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="main-panel">
|
||
<header class="topbar">
|
||
<div class="topbar-left">
|
||
<button class="hamburger-container" type="button" title="折叠菜单" @click="toggleSidebar">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</button>
|
||
<div class="breadcrumb-container">
|
||
<span>首页</span>
|
||
<span class="breadcrumb-separator">/</span>
|
||
<strong>{{ currentTitle }}</strong>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<div
|
||
v-if="activeBroadcast"
|
||
:key="activeBroadcast.broadcast_id"
|
||
class="broadcast-banner"
|
||
:class="`broadcast-${String(activeBroadcast.tone || 'INFO').toLowerCase()}`"
|
||
>
|
||
<span class="broadcast-speaker" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" role="img">
|
||
<path d="M4 10.5H7.5L16 6.5V17.5L7.5 13.5H4V10.5Z" />
|
||
<path d="M7.5 13.5L9.2 19H12.2L10.4 14.8" />
|
||
<path d="M18.3 9.2L20.5 8" />
|
||
<path d="M18.7 12H21.4" />
|
||
<path d="M18.3 14.8L20.5 16" />
|
||
</svg>
|
||
</span>
|
||
<div class="broadcast-marquee" aria-live="polite">
|
||
<div class="broadcast-marquee-track">
|
||
<strong>{{ activeBroadcast.title }}</strong>
|
||
<span>{{ activeBroadcast.content }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-meta">
|
||
<button class="assistant-bot-button" type="button" title="打开 AI 助手" @click="openAssistantDrawer">
|
||
<span class="assistant-bot-wave">HI</span>
|
||
<span class="assistant-bot-face">
|
||
<span class="assistant-bot-eye"></span>
|
||
<span class="assistant-bot-eye"></span>
|
||
</span>
|
||
</button>
|
||
<div ref="userMenuShellRef" class="user-menu-shell">
|
||
<button class="user-avatar-button" type="button" @click="toggleUserMenu">
|
||
<span class="user-avatar">{{ avatarInitial }}</span>
|
||
<span class="user-avatar-meta">
|
||
<strong>{{ currentUser?.display_name || "未登录" }}</strong>
|
||
<small>{{ currentUser?.title || "工业风管理台" }}</small>
|
||
</span>
|
||
</button>
|
||
<div v-if="userMenuOpen" class="user-menu-dropdown">
|
||
<button type="button" @click="openChangePassword">修改密码</button>
|
||
<button v-if="isSystemAdmin" type="button" @click="openResetPassword">重置他人密码</button>
|
||
<button type="button" class="danger-menu-item" @click="logout">退出系统</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="tags-view-container" aria-label="已打开页面">
|
||
<span
|
||
v-for="tag in visitedTags"
|
||
:key="tag.fullPath"
|
||
class="tags-view-item"
|
||
:class="{ active: isTagActive(tag) }"
|
||
:title="tag.title"
|
||
@contextmenu.prevent="openTagContextMenu($event, tag)"
|
||
>
|
||
<button class="tags-view-open" type="button" @click="openVisitedTag(tag)">
|
||
<span class="tags-view-title">{{ tag.title }}</span>
|
||
</button>
|
||
<button
|
||
class="tags-view-close"
|
||
aria-label="关闭页签"
|
||
type="button"
|
||
title="关闭"
|
||
@click="closeVisitedTag(tag)"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
</nav>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="tagContextMenu.open"
|
||
class="tags-context-menu"
|
||
:style="{ left: `${tagContextMenu.x}px`, top: `${tagContextMenu.y}px` }"
|
||
role="menu"
|
||
>
|
||
<button type="button" role="menuitem" @click="handleTagContextAction('current')">关闭当前</button>
|
||
<button type="button" role="menuitem" :disabled="!canCloseLeftTags" @click="handleTagContextAction('left')">关闭左侧</button>
|
||
<button type="button" role="menuitem" :disabled="!canCloseRightTags" @click="handleTagContextAction('right')">关闭右侧</button>
|
||
<button type="button" role="menuitem" :disabled="visitedTags.length <= 1" @click="handleTagContextAction('others')">关闭其他</button>
|
||
<button type="button" role="menuitem" class="tags-context-danger" @click="handleTagContextAction('all')">关闭全部</button>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<section class="app-main">
|
||
<RouterView />
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="assistantDrawerOpen" class="workflow-drawer-mask assistant-chat-mask" @click.self="closeAssistantDrawer">
|
||
<aside class="assistant-chat-drawer">
|
||
<header class="assistant-chat-head">
|
||
<div class="assistant-chat-title">
|
||
<span class="assistant-chat-avatar">
|
||
<span class="assistant-bot-face">
|
||
<span class="assistant-bot-eye"></span>
|
||
<span class="assistant-bot-eye"></span>
|
||
</span>
|
||
</span>
|
||
<div>
|
||
<p class="eyebrow">AI 助手</p>
|
||
<h3>百华工艺助手</h3>
|
||
</div>
|
||
</div>
|
||
<button class="ghost-button" type="button" @click="closeAssistantDrawer">关闭</button>
|
||
</header>
|
||
|
||
<div class="assistant-chat-body">
|
||
<article
|
||
v-for="message in chatMessages"
|
||
:key="message.id"
|
||
class="assistant-message"
|
||
:class="`assistant-message-${message.role}`"
|
||
>
|
||
<div class="assistant-message-bubble">{{ message.content }}</div>
|
||
</article>
|
||
</div>
|
||
|
||
<form class="assistant-chat-input" @submit.prevent="sendAssistantMessage">
|
||
<textarea v-model.trim="chatInput" rows="2" placeholder="输入你想问的 ERP 操作、字段含义、流程问题..."></textarea>
|
||
<button class="primary-button" type="submit">发送</button>
|
||
</form>
|
||
</aside>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="changePasswordOpen" class="workflow-drawer-mask password-modal-mask" @click.self="closePasswordModals">
|
||
<section class="password-modal-card">
|
||
<header class="password-modal-head">
|
||
<div>
|
||
<p class="eyebrow">账号安全</p>
|
||
<h3>修改密码</h3>
|
||
<p>当前账号:{{ currentUser?.username }}</p>
|
||
</div>
|
||
<button class="ghost-button" type="button" @click="closePasswordModals">关闭</button>
|
||
</header>
|
||
<form class="stack-form" @submit.prevent="submitChangePassword">
|
||
<label class="form-field">
|
||
<span>原密码</span>
|
||
<input v-model="changePasswordForm.old_password" type="password" autocomplete="current-password" required />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>新密码</span>
|
||
<input v-model="changePasswordForm.new_password" type="password" autocomplete="new-password" minlength="6" required />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>确认新密码</span>
|
||
<input v-model="changePasswordForm.confirm_password" type="password" autocomplete="new-password" minlength="6" required />
|
||
</label>
|
||
<button class="primary-button" type="submit" :disabled="passwordSubmitting">
|
||
{{ passwordSubmitting ? "提交中..." : "确认修改" }}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="resetPasswordOpen" class="workflow-drawer-mask password-modal-mask" @click.self="closePasswordModals">
|
||
<section class="password-modal-card">
|
||
<header class="password-modal-head">
|
||
<div>
|
||
<p class="eyebrow">管理员操作</p>
|
||
<h3>重置他人密码</h3>
|
||
</div>
|
||
<button class="ghost-button" type="button" @click="closePasswordModals">关闭</button>
|
||
</header>
|
||
<form class="stack-form" @submit.prevent="submitResetPassword">
|
||
<label class="form-field">
|
||
<span>账号</span>
|
||
<select v-model="resetPasswordForm.username" required>
|
||
<option disabled value="">
|
||
{{ resetPasswordUsersLoading ? "正在加载系统账号..." : "请选择需要重置密码的账号" }}
|
||
</option>
|
||
<option v-for="user in resetPasswordUserOptions" :key="user.user_id || user.username" :value="user.username">
|
||
{{ formatResetPasswordUserLabel(user) }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="form-field">
|
||
<span>新密码</span>
|
||
<input v-model="resetPasswordForm.new_password" type="password" autocomplete="new-password" minlength="6" required />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>确认新密码</span>
|
||
<input v-model="resetPasswordForm.confirm_password" type="password" autocomplete="new-password" minlength="6" required />
|
||
</label>
|
||
<button class="primary-button" type="submit" :disabled="passwordSubmitting">
|
||
{{ passwordSubmitting ? "提交中..." : "确认重置" }}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<GlobalSearchableSelect />
|
||
<NotificationStack />
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||
|
||
import AppIcon from "./components/AppIcon.vue";
|
||
import GlobalSearchableSelect from "./components/GlobalSearchableSelect.vue";
|
||
import NotificationStack from "./components/NotificationStack.vue";
|
||
import { useBodyScrollLock } from "./composables/useBodyScrollLock";
|
||
import { clearSession, fetchResource, postResource, readSession } from "./services/api";
|
||
import { loadSmartOperationReportConfig } from "./services/systemFeatures";
|
||
import { setupActionIconButtons } from "./utils/actionIconButtons";
|
||
import { notifyError, notifySuccess, useDomNotificationBridge } from "./utils/notifications";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const currentUser = ref(readSession());
|
||
const activeBroadcasts = ref([]);
|
||
const activeBroadcastIndex = ref(0);
|
||
const assistantDrawerOpen = ref(false);
|
||
const userMenuOpen = ref(false);
|
||
const userMenuShellRef = ref(null);
|
||
const changePasswordOpen = ref(false);
|
||
const resetPasswordOpen = ref(false);
|
||
const passwordSubmitting = ref(false);
|
||
const resetPasswordUsers = ref([]);
|
||
const resetPasswordUsersLoading = ref(false);
|
||
const chatInput = ref("");
|
||
const sidebarCollapsed = ref(false);
|
||
const narrowShell = ref(false);
|
||
const expandedStageKeys = ref(new Set());
|
||
const visitedTags = ref([]);
|
||
const tagContextMenu = ref({ open: false, x: 0, y: 0, tag: null });
|
||
const smartOperationReportEnabled = ref(true);
|
||
let broadcastTimer = null;
|
||
let broadcastRotateTimer = null;
|
||
let cleanupActionIconButtons = null;
|
||
const chatMessages = ref([
|
||
{
|
||
id: 1,
|
||
role: "assistant",
|
||
content: "你好,我是百华工艺助手。你可以问我系统操作路径、字段含义、业务流程或异常排查。"
|
||
}
|
||
]);
|
||
const changePasswordForm = ref({
|
||
old_password: "",
|
||
new_password: "",
|
||
confirm_password: ""
|
||
});
|
||
const resetPasswordForm = ref({
|
||
username: "",
|
||
new_password: "",
|
||
confirm_password: ""
|
||
});
|
||
|
||
const activeTagContextIndex = computed(() =>
|
||
visitedTags.value.findIndex((item) => item.fullPath === tagContextMenu.value.tag?.fullPath)
|
||
);
|
||
const canCloseLeftTags = computed(() => activeTagContextIndex.value > 0);
|
||
const canCloseRightTags = computed(() => activeTagContextIndex.value >= 0 && activeTagContextIndex.value < visitedTags.value.length - 1);
|
||
const resetPasswordUserOptions = computed(() =>
|
||
[...resetPasswordUsers.value].sort((left, right) => {
|
||
const leftStatus = left?.status === "ACTIVE" ? 0 : 1;
|
||
const rightStatus = right?.status === "ACTIVE" ? 0 : 1;
|
||
if (leftStatus !== rightStatus) {
|
||
return leftStatus - rightStatus;
|
||
}
|
||
return String(left?.username || "").localeCompare(String(right?.username || ""), "zh-Hans-CN");
|
||
})
|
||
);
|
||
|
||
useDomNotificationBridge();
|
||
useBodyScrollLock(computed(() => assistantDrawerOpen.value || changePasswordOpen.value || resetPasswordOpen.value));
|
||
|
||
const navStages = [
|
||
{
|
||
key: "dashboard",
|
||
step: "01",
|
||
label: "经营总览",
|
||
desc: "先看原材料、成品库存、生产和成本全局态势。",
|
||
tail: "总览",
|
||
icon: "dashboard",
|
||
iconPaths: [
|
||
"M4 19H20",
|
||
"M6 16V10",
|
||
"M11 16V6",
|
||
"M16 16V12",
|
||
"M5 5H19"
|
||
],
|
||
to: { name: "dashboard" },
|
||
routeNames: ["dashboard"],
|
||
permissionsAny: ["MENU_DASHBOARD"]
|
||
},
|
||
{
|
||
key: "master-data",
|
||
step: "02",
|
||
label: "基础资料",
|
||
desc: "供应商名录、原材料名录、客户名录、产品需规清单和员工资料。",
|
||
tail: "底座",
|
||
icon: "masterData",
|
||
iconPaths: [
|
||
"M4 6H20",
|
||
"M4 12H20",
|
||
"M4 18H20",
|
||
"M8 4V8",
|
||
"M15 10V14",
|
||
"M11 16V20"
|
||
],
|
||
to: { name: "master-data-workspace" },
|
||
routeNames: [
|
||
"master-data-workspace",
|
||
"supplier-management",
|
||
"supplier-material-lite",
|
||
"customer-management",
|
||
"product-spec-lite",
|
||
"product-management",
|
||
"material-management",
|
||
"bom-management",
|
||
"process-route-management",
|
||
"employee-management"
|
||
],
|
||
permissionsAny: [
|
||
"MENU_PRODUCT_MANAGEMENT",
|
||
"MENU_SUPPLIER_MANAGEMENT",
|
||
"MENU_MATERIAL_MANAGEMENT",
|
||
"MENU_BOM_MANAGEMENT",
|
||
"MENU_ROUTE_MANAGEMENT",
|
||
"MENU_EMPLOYEE_MANAGEMENT",
|
||
"MENU_SALES_PLANNING"
|
||
],
|
||
children: [
|
||
{
|
||
key: "supplier-management",
|
||
label: "供应商名录",
|
||
icon: "supplier",
|
||
to: { name: "supplier-management" },
|
||
routeNames: ["supplier-management"],
|
||
permission: "MENU_SUPPLIER_MANAGEMENT",
|
||
iconPaths: [
|
||
"M5 5H19V19H5V5Z",
|
||
"M8 9H16",
|
||
"M8 13H16",
|
||
"M8 17H13"
|
||
]
|
||
},
|
||
{
|
||
key: "supplier-material-lite",
|
||
label: "原材料名录",
|
||
icon: "material",
|
||
to: { name: "supplier-material-lite" },
|
||
routeNames: ["supplier-material-lite"],
|
||
permissionsAny: ["MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT"],
|
||
iconPaths: [
|
||
"M6 8A3 3 0 1 0 6 14A3 3 0 0 0 6 8Z",
|
||
"M3 20C3 17.8 4.4 16 6 16C7.6 16 9 17.8 9 20",
|
||
"M13 6H21V12H13V6Z",
|
||
"M13 16H21"
|
||
]
|
||
},
|
||
{
|
||
key: "customer-management",
|
||
label: "客户名录",
|
||
icon: "customer",
|
||
to: { name: "customer-management" },
|
||
routeNames: ["customer-management"],
|
||
permission: "MENU_SALES_PLANNING",
|
||
iconPaths: [
|
||
"M12 5A4 4 0 1 0 12 13A4 4 0 0 0 12 5Z",
|
||
"M5 21C5 17.7 8 15 12 15C16 15 19 17.7 19 21",
|
||
"M17 8H21",
|
||
"M17 12H21"
|
||
]
|
||
},
|
||
{
|
||
key: "product-spec-lite",
|
||
label: "产品需规清单",
|
||
icon: "productSpec",
|
||
to: { name: "product-spec-lite" },
|
||
routeNames: ["product-spec-lite"],
|
||
permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"],
|
||
iconPaths: [
|
||
"M5 4H19V20H5V4Z",
|
||
"M8 8H16",
|
||
"M8 12H16",
|
||
"M8 16H13"
|
||
]
|
||
},
|
||
{
|
||
key: "employee-management",
|
||
label: "员工管理",
|
||
icon: "employee",
|
||
to: { name: "employee-management" },
|
||
routeNames: ["employee-management"],
|
||
permission: "MENU_EMPLOYEE_MANAGEMENT",
|
||
iconPaths: [
|
||
"M12 5A4 4 0 1 0 12 13A4 4 0 0 0 12 5Z",
|
||
"M5 21C5 17.7 8 15 12 15C16 15 19 17.7 19 21"
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
key: "sales-planning",
|
||
step: "03",
|
||
label: "订单与计划",
|
||
desc: "销售订单和发货履约追踪。",
|
||
tail: "接单",
|
||
icon: "order",
|
||
iconPaths: [
|
||
"M7 4H17L20 7V20H4V4H7Z",
|
||
"M17 4V8H20",
|
||
"M8 11H16",
|
||
"M8 15H14"
|
||
],
|
||
to: { name: "sales-planning" },
|
||
routeNames: ["sales-planning", "order-list"],
|
||
permissionsAny: ["MENU_SALES_PLANNING"],
|
||
children: [
|
||
{
|
||
key: "order-list",
|
||
label: "销售订单列表",
|
||
icon: "ledger",
|
||
to: { name: "order-list" },
|
||
routeNames: ["order-list"],
|
||
permission: "MENU_SALES_PLANNING",
|
||
iconPaths: [
|
||
"M6 4H18V20H6V4Z",
|
||
"M9 8H15",
|
||
"M9 12H15",
|
||
"M9 16H13"
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
key: "procurement",
|
||
step: "04",
|
||
label: "采购与库存",
|
||
desc: "采购、自家料入库、质量检验和百华仓库。",
|
||
tail: "采购",
|
||
icon: "warehouse",
|
||
iconPaths: [
|
||
"M4 7L12 3L20 7L12 11L4 7Z",
|
||
"M4 7V16L12 21L20 16V7",
|
||
"M12 11V21"
|
||
],
|
||
to: { name: "procurement-workspace" },
|
||
routeNames: [
|
||
"procurement-workspace",
|
||
"purchase-orders",
|
||
"purchase-receipts",
|
||
"quality-inspection",
|
||
"inventory-ledger"
|
||
],
|
||
permissionsAny: [
|
||
"MENU_PURCHASE_ORDER",
|
||
"MENU_PURCHASE_RECEIPT",
|
||
"MENU_QUALITY_INSPECTION",
|
||
"MENU_INVENTORY_LEDGER"
|
||
],
|
||
children: [
|
||
{
|
||
key: "purchase-orders",
|
||
label: "采购订单",
|
||
icon: "purchaseOrder",
|
||
to: { name: "purchase-orders" },
|
||
routeNames: ["purchase-orders"],
|
||
permission: "MENU_PURCHASE_ORDER",
|
||
iconPaths: [
|
||
"M6 4H16L19 7V20H6V4Z",
|
||
"M16 4V8H19",
|
||
"M8 11H13",
|
||
"M8 15H12",
|
||
"M14 16L16 18L20 13"
|
||
]
|
||
},
|
||
{
|
||
key: "purchase-receipts",
|
||
label: "到货入库",
|
||
icon: "receipt",
|
||
to: { name: "purchase-receipts" },
|
||
routeNames: ["purchase-receipts"],
|
||
permission: "MENU_PURCHASE_RECEIPT",
|
||
iconPaths: [
|
||
"M4 9L12 5L20 9L12 13L4 9Z",
|
||
"M4 13V17L12 21L20 17V13",
|
||
"M12 3V11",
|
||
"M9 8L12 11L15 8"
|
||
]
|
||
},
|
||
{
|
||
key: "quality-inspection",
|
||
label: "质量检验",
|
||
icon: "quality",
|
||
to: { name: "quality-inspection" },
|
||
routeNames: ["quality-inspection"],
|
||
permission: "MENU_QUALITY_INSPECTION",
|
||
iconPaths: [
|
||
"M5 12L10 17L20 7",
|
||
"M4 4H20V20H4V4Z"
|
||
]
|
||
},
|
||
{
|
||
key: "inventory-ledger",
|
||
label: "百华仓库",
|
||
icon: "warehouse",
|
||
to: { name: "inventory-ledger" },
|
||
routeNames: ["inventory-ledger"],
|
||
permission: "MENU_INVENTORY_LEDGER",
|
||
iconPaths: [
|
||
"M4 8L12 4L20 8L12 12L4 8Z",
|
||
"M4 12L12 16L20 12",
|
||
"M4 16L12 20L20 16"
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
key: "production",
|
||
step: "05",
|
||
label: "生产执行",
|
||
desc: "生产台账和小程序报工。",
|
||
tail: "车间",
|
||
icon: "production",
|
||
iconPaths: [
|
||
"M12 8A4 4 0 1 1 12 16A4 4 0 0 1 12 8Z",
|
||
"M12 2V5",
|
||
"M12 19V22",
|
||
"M4.9 4.9L7 7",
|
||
"M17 17L19.1 19.1",
|
||
"M2 12H5",
|
||
"M19 12H22",
|
||
"M4.9 19.1L7 17",
|
||
"M17 7L19.1 4.9"
|
||
],
|
||
to: { name: "production-workspace" },
|
||
routeNames: [
|
||
"production-workspace",
|
||
"production-ledger",
|
||
"work-order-ledger",
|
||
"operation-reports"
|
||
],
|
||
permissionsAny: [
|
||
"MENU_WORK_ORDER",
|
||
"MENU_OPERATION_REPORT"
|
||
],
|
||
children: [
|
||
{
|
||
key: "production-ledger",
|
||
label: "生产台账",
|
||
icon: "workOrder",
|
||
to: { name: "production-ledger" },
|
||
routeNames: ["production-ledger", "work-order-ledger"],
|
||
permission: "MENU_WORK_ORDER",
|
||
iconPaths: [
|
||
"M7 4H17V20H7V4Z",
|
||
"M9 4V2H15V4",
|
||
"M10 9H14",
|
||
"M10 13H13",
|
||
"M15.5 15.5A2 2 0 1 0 15.5 19.5A2 2 0 0 0 15.5 15.5Z"
|
||
]
|
||
},
|
||
{
|
||
key: "operation-reports",
|
||
label: "工序报工",
|
||
icon: "operationReport",
|
||
to: { name: "operation-reports" },
|
||
routeNames: ["operation-reports"],
|
||
permission: "MENU_OPERATION_REPORT",
|
||
iconPaths: [
|
||
"M5 5H19V19H5V5Z",
|
||
"M8 9H12",
|
||
"M8 13H12",
|
||
"M15 9L18 12L15 15",
|
||
"M14 17H18"
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
key: "delivery",
|
||
step: "06",
|
||
label: "发货概览",
|
||
desc: "发运、退货、返工和报废放在同一交付闭环。",
|
||
tail: "交付",
|
||
icon: "delivery",
|
||
iconPaths: [
|
||
"M3 7H14V17H3V7Z",
|
||
"M14 10H18L21 13V17H14V10Z",
|
||
"M7 20A2 2 0 1 0 7 16A2 2 0 0 0 7 20Z",
|
||
"M17 20A2 2 0 1 0 17 16A2 2 0 0 0 17 20Z"
|
||
],
|
||
to: { name: "delivery-workspace" },
|
||
routeNames: ["delivery-workspace", "delivery-management"],
|
||
permissionsAny: ["MENU_DELIVERY_MANAGEMENT"],
|
||
children: [
|
||
{
|
||
key: "delivery-management",
|
||
label: "发货台账",
|
||
icon: "delivery",
|
||
to: { name: "delivery-management" },
|
||
routeNames: ["delivery-management"],
|
||
permission: "MENU_DELIVERY_MANAGEMENT",
|
||
iconPaths: [
|
||
"M5 5H13",
|
||
"M5 9H11",
|
||
"M5 13H10",
|
||
"M14 6H18L20 8V18H14V6Z",
|
||
"M14 18L10 21L6 18"
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
key: "finance",
|
||
step: "07",
|
||
label: "财务分析",
|
||
desc: "按时间刷新冲压产品材料成本核算、费用归集和利润结果。",
|
||
tail: "财务",
|
||
icon: "finance",
|
||
iconPaths: [
|
||
"M5 19V5",
|
||
"M5 19H20",
|
||
"M8 15L11 12L14 14L19 8",
|
||
"M16 8H19V11"
|
||
],
|
||
to: { name: "finance-reports" },
|
||
routeNames: ["finance-reports"],
|
||
permissionsAny: ["MENU_FINANCE_REPORT"]
|
||
},
|
||
{
|
||
key: "system-management",
|
||
step: "08",
|
||
label: "系统管理",
|
||
desc: "系统权限、广播通知、AI 助手和后续平台扩展统一管理。",
|
||
tail: "系统",
|
||
icon: "system",
|
||
iconPaths: [
|
||
"M5 12H19",
|
||
"M12 5V19",
|
||
"M7 7L17 17",
|
||
"M17 7L7 17"
|
||
],
|
||
to: { name: "system-management" },
|
||
routeNames: ["system-management", "system-permissions", "system-extension"],
|
||
permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"],
|
||
children: [
|
||
{
|
||
key: "system-permissions",
|
||
label: "系统权限管理",
|
||
icon: "permissions",
|
||
to: { name: "system-permissions" },
|
||
routeNames: ["system-permissions"],
|
||
permission: "MENU_SYSTEM_PERMISSION",
|
||
iconPaths: [
|
||
"M12 4A4 4 0 1 0 12 12A4 4 0 0 0 12 4Z",
|
||
"M5 21C5 17.7 8 15 12 15C16 15 19 17.7 19 21",
|
||
"M17 5H21V9"
|
||
]
|
||
},
|
||
{
|
||
key: "system-extension",
|
||
label: "系统拓展",
|
||
icon: "extension",
|
||
to: { name: "system-extension" },
|
||
routeNames: ["system-extension"],
|
||
permission: "MENU_SYSTEM_EXTENSION",
|
||
iconPaths: [
|
||
"M7 4H13V8H17V14H13V20H7V14H3V8H7V4Z",
|
||
"M9 6V2",
|
||
"M11 6V2",
|
||
"M9 22V18",
|
||
"M11 22V18"
|
||
]
|
||
}
|
||
]
|
||
},
|
||
];
|
||
|
||
const isAuthLayout = computed(() => route.meta.layout === "auth");
|
||
const currentTitle = computed(() => route.meta.title || "百华智能五金 ERP");
|
||
const shellClass = computed(() => ({
|
||
shell: true,
|
||
"admin-shell": true,
|
||
"app-shell": true,
|
||
"sidebar-collapsed": sidebarCollapsed.value,
|
||
"app-shell-collapsed": sidebarCollapsed.value,
|
||
"app-shell-narrow": narrowShell.value
|
||
}));
|
||
const activeBroadcast = computed(() => {
|
||
if (!activeBroadcasts.value.length) {
|
||
return null;
|
||
}
|
||
return activeBroadcasts.value[activeBroadcastIndex.value % activeBroadcasts.value.length];
|
||
});
|
||
const avatarInitial = computed(() => {
|
||
const name = currentUser.value?.display_name || currentUser.value?.username || "JH";
|
||
return String(name).trim().slice(0, 1).toUpperCase() || "J";
|
||
});
|
||
const isSystemAdmin = computed(() => {
|
||
const roleCodes = currentUser.value?.role_codes || [];
|
||
const permissionCodes = currentUser.value?.permission_codes || [];
|
||
return (
|
||
roleCodes.includes("ADMIN") ||
|
||
permissionCodes.includes("MENU_SYSTEM_PERMISSION") ||
|
||
permissionCodes.includes("MENU_SYSTEM_EXTENSION")
|
||
);
|
||
});
|
||
|
||
function hasSystemAdminFallback(permission, roleCodes = currentUser.value?.role_codes || []) {
|
||
return String(permission || "").startsWith("MENU_SYSTEM_") && roleCodes.includes("ADMIN");
|
||
}
|
||
|
||
const filteredNavStages = computed(() => {
|
||
return navStages.filter((stage) => {
|
||
if (stage.children?.length) {
|
||
return visibleStageChildren(stage).length > 0;
|
||
}
|
||
return hasPermission(stage);
|
||
});
|
||
});
|
||
|
||
function isStageActive(stage) {
|
||
return stage.routeNames.includes(String(route.name || ""));
|
||
}
|
||
|
||
function hasPermission(item) {
|
||
const permissionCodes = currentUser.value?.permission_codes || [];
|
||
const roleCodes = currentUser.value?.role_codes || [];
|
||
if (item.permission) {
|
||
return permissionCodes.includes(item.permission) || hasSystemAdminFallback(item.permission, roleCodes);
|
||
}
|
||
if (item.permissionsAny?.length) {
|
||
return item.permissionsAny.some((permission) => permissionCodes.includes(permission) || hasSystemAdminFallback(permission, roleCodes));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function visibleStageChildren(stage) {
|
||
return (stage.children || []).filter((child) => {
|
||
if (child.key === "operation-reports" && !smartOperationReportEnabled.value) {
|
||
return false;
|
||
}
|
||
return hasPermission(child);
|
||
});
|
||
}
|
||
|
||
function getStageTarget(stage) {
|
||
return visibleStageChildren(stage)[0]?.to || stage.to;
|
||
}
|
||
|
||
function setStageExpanded(stageKey, expanded) {
|
||
const nextKeys = new Set(expandedStageKeys.value);
|
||
if (expanded) {
|
||
nextKeys.add(stageKey);
|
||
} else {
|
||
nextKeys.delete(stageKey);
|
||
}
|
||
expandedStageKeys.value = nextKeys;
|
||
}
|
||
|
||
function isStageExpanded(stage) {
|
||
return expandedStageKeys.value.has(stage.key);
|
||
}
|
||
|
||
function syncActiveStageExpansion() {
|
||
const activeStage = filteredNavStages.value.find((stage) => isStageActive(stage) && visibleStageChildren(stage).length);
|
||
if (activeStage) {
|
||
setStageExpanded(activeStage.key, true);
|
||
}
|
||
}
|
||
|
||
function handleStageClick(stage) {
|
||
const children = visibleStageChildren(stage);
|
||
if (!children.length) {
|
||
expandedStageKeys.value = new Set();
|
||
if (!isStageActive(stage)) {
|
||
router.push(stage.to);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isStageExpanded(stage)) {
|
||
setStageExpanded(stage.key, false);
|
||
return;
|
||
}
|
||
|
||
expandedStageKeys.value = new Set([stage.key]);
|
||
if (!isStageActive(stage)) {
|
||
router.push(children[0].to);
|
||
}
|
||
}
|
||
|
||
function isChildActive(child) {
|
||
const routeName = String(route.name || "");
|
||
return child.routeNames?.includes(routeName) || routeName === child.to?.name;
|
||
}
|
||
|
||
function buildRouteTag(targetRoute = route) {
|
||
if (!targetRoute?.name || targetRoute.meta?.layout === "auth") {
|
||
return null;
|
||
}
|
||
return {
|
||
name: targetRoute.name,
|
||
path: targetRoute.path,
|
||
fullPath: targetRoute.fullPath || targetRoute.path,
|
||
title: targetRoute.meta?.title || "未命名页面"
|
||
};
|
||
}
|
||
|
||
function addVisitedTag(targetRoute = route) {
|
||
const tag = buildRouteTag(targetRoute);
|
||
if (!tag) {
|
||
return;
|
||
}
|
||
const existingIndex = visitedTags.value.findIndex((item) => item.name === tag.name);
|
||
if (existingIndex >= 0) {
|
||
const nextTags = [...visitedTags.value];
|
||
nextTags.splice(existingIndex, 1, tag);
|
||
visitedTags.value = nextTags;
|
||
return;
|
||
}
|
||
visitedTags.value = [...visitedTags.value, tag];
|
||
}
|
||
|
||
function isTagActive(tag) {
|
||
return tag.name === route.name || tag.fullPath === route.fullPath;
|
||
}
|
||
|
||
function openVisitedTag(tag) {
|
||
closeTagContextMenu();
|
||
if (isTagActive(tag)) {
|
||
return;
|
||
}
|
||
router.push(tag.fullPath || { name: tag.name });
|
||
}
|
||
|
||
function getDefaultAccessibleRoute() {
|
||
const firstStage = filteredNavStages.value[0];
|
||
return firstStage ? getStageTarget(firstStage) : { name: "dashboard" };
|
||
}
|
||
|
||
function closeVisitedTag(tag) {
|
||
closeTagContextMenu();
|
||
const closedIndex = visitedTags.value.findIndex((item) => item.fullPath === tag.fullPath);
|
||
if (closedIndex < 0) {
|
||
return;
|
||
}
|
||
const wasActive = isTagActive(tag);
|
||
const nextTags = visitedTags.value.filter((item) => item.fullPath !== tag.fullPath);
|
||
visitedTags.value = nextTags;
|
||
|
||
if (!wasActive) {
|
||
return;
|
||
}
|
||
|
||
const fallbackTag = nextTags[Math.max(closedIndex - 1, 0)] || nextTags[nextTags.length - 1];
|
||
if (fallbackTag) {
|
||
router.push(fallbackTag.fullPath || { name: fallbackTag.name });
|
||
return;
|
||
}
|
||
router.push(getDefaultAccessibleRoute());
|
||
}
|
||
|
||
function getTagRouteTarget(tag) {
|
||
return tag?.fullPath || (tag?.name ? { name: tag.name } : getDefaultAccessibleRoute());
|
||
}
|
||
|
||
function updateVisitedTags(nextTags, fallbackTag) {
|
||
visitedTags.value = nextTags;
|
||
const currentStillOpen = nextTags.some((item) => isTagActive(item));
|
||
if (currentStillOpen) {
|
||
return;
|
||
}
|
||
router.push(getTagRouteTarget(fallbackTag || nextTags[nextTags.length - 1]));
|
||
}
|
||
|
||
function openTagContextMenu(event, tag) {
|
||
const menuWidth = 148;
|
||
const menuHeight = 188;
|
||
tagContextMenu.value = {
|
||
open: true,
|
||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - menuWidth - 8)),
|
||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - menuHeight - 8)),
|
||
tag
|
||
};
|
||
}
|
||
|
||
function closeTagContextMenu() {
|
||
if (!tagContextMenu.value.open) {
|
||
return;
|
||
}
|
||
tagContextMenu.value = { open: false, x: 0, y: 0, tag: null };
|
||
}
|
||
|
||
function handleTagContextAction(action) {
|
||
const targetTag = tagContextMenu.value.tag;
|
||
const targetIndex = visitedTags.value.findIndex((item) => item.fullPath === targetTag?.fullPath);
|
||
closeTagContextMenu();
|
||
if (action === "all") {
|
||
updateVisitedTags([], null);
|
||
return;
|
||
}
|
||
if (!targetTag || targetIndex < 0) {
|
||
return;
|
||
}
|
||
if (action === "current") {
|
||
closeVisitedTag(targetTag);
|
||
return;
|
||
}
|
||
if (action === "left") {
|
||
updateVisitedTags(visitedTags.value.slice(targetIndex), targetTag);
|
||
return;
|
||
}
|
||
if (action === "right") {
|
||
updateVisitedTags(visitedTags.value.slice(0, targetIndex + 1), targetTag);
|
||
return;
|
||
}
|
||
if (action === "others") {
|
||
updateVisitedTags([targetTag], targetTag);
|
||
}
|
||
}
|
||
|
||
function handleGlobalKeydown(event) {
|
||
if (event.key === "Escape") {
|
||
closeTagContextMenu();
|
||
userMenuOpen.value = false;
|
||
}
|
||
}
|
||
|
||
function handleUserMenuOutsidePointerDown(event) {
|
||
if (!userMenuOpen.value) {
|
||
return;
|
||
}
|
||
if (userMenuShellRef.value?.contains(event.target)) {
|
||
return;
|
||
}
|
||
userMenuOpen.value = false;
|
||
}
|
||
|
||
function updateShellViewportState() {
|
||
narrowShell.value = window.innerWidth < 1024;
|
||
if (narrowShell.value) {
|
||
sidebarCollapsed.value = true;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => route.fullPath,
|
||
() => {
|
||
currentUser.value = readSession();
|
||
userMenuOpen.value = false;
|
||
closeTagContextMenu();
|
||
syncActiveStageExpansion();
|
||
addVisitedTag(route);
|
||
void loadActiveBroadcast();
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
onMounted(() => {
|
||
cleanupActionIconButtons = setupActionIconButtons();
|
||
updateShellViewportState();
|
||
window.addEventListener("resize", updateShellViewportState);
|
||
window.addEventListener("click", closeTagContextMenu);
|
||
window.addEventListener("keydown", handleGlobalKeydown);
|
||
document.addEventListener("pointerdown", handleUserMenuOutsidePointerDown);
|
||
void loadActiveBroadcast();
|
||
void loadSmartOperationFeature();
|
||
broadcastTimer = window.setInterval(() => {
|
||
void loadActiveBroadcast();
|
||
}, 30000);
|
||
broadcastRotateTimer = window.setInterval(() => {
|
||
rotateBroadcast();
|
||
}, 18000);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener("resize", updateShellViewportState);
|
||
window.removeEventListener("click", closeTagContextMenu);
|
||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||
document.removeEventListener("pointerdown", handleUserMenuOutsidePointerDown);
|
||
if (broadcastTimer) {
|
||
window.clearInterval(broadcastTimer);
|
||
broadcastTimer = null;
|
||
}
|
||
if (broadcastRotateTimer) {
|
||
window.clearInterval(broadcastRotateTimer);
|
||
broadcastRotateTimer = null;
|
||
}
|
||
cleanupActionIconButtons?.();
|
||
cleanupActionIconButtons = null;
|
||
});
|
||
|
||
async function loadActiveBroadcast() {
|
||
if (!currentUser.value) {
|
||
activeBroadcasts.value = [];
|
||
activeBroadcastIndex.value = 0;
|
||
return;
|
||
}
|
||
try {
|
||
const rows = await fetchResource("/system-extension/active-broadcasts", []);
|
||
activeBroadcasts.value = Array.isArray(rows) ? rows : [];
|
||
if (activeBroadcastIndex.value >= activeBroadcasts.value.length) {
|
||
activeBroadcastIndex.value = 0;
|
||
}
|
||
} catch (error) {
|
||
activeBroadcasts.value = [];
|
||
activeBroadcastIndex.value = 0;
|
||
}
|
||
}
|
||
|
||
async function loadSmartOperationFeature() {
|
||
if (!currentUser.value) {
|
||
smartOperationReportEnabled.value = true;
|
||
return;
|
||
}
|
||
try {
|
||
const config = await loadSmartOperationReportConfig(true);
|
||
smartOperationReportEnabled.value = config.enabled !== false;
|
||
syncActiveStageExpansion();
|
||
} catch (error) {
|
||
smartOperationReportEnabled.value = true;
|
||
}
|
||
}
|
||
|
||
function openAssistantDrawer() {
|
||
assistantDrawerOpen.value = true;
|
||
}
|
||
|
||
function closeAssistantDrawer() {
|
||
assistantDrawerOpen.value = false;
|
||
}
|
||
|
||
function toggleUserMenu() {
|
||
userMenuOpen.value = !userMenuOpen.value;
|
||
}
|
||
|
||
function resetPasswordForms() {
|
||
changePasswordForm.value = {
|
||
old_password: "",
|
||
new_password: "",
|
||
confirm_password: ""
|
||
};
|
||
resetPasswordForm.value = {
|
||
username: "",
|
||
new_password: "",
|
||
confirm_password: ""
|
||
};
|
||
}
|
||
|
||
function openChangePassword() {
|
||
userMenuOpen.value = false;
|
||
resetPasswordForms();
|
||
changePasswordOpen.value = true;
|
||
}
|
||
|
||
function openResetPassword() {
|
||
userMenuOpen.value = false;
|
||
resetPasswordForms();
|
||
resetPasswordOpen.value = true;
|
||
void loadResetPasswordUsers();
|
||
}
|
||
|
||
function closePasswordModals() {
|
||
changePasswordOpen.value = false;
|
||
resetPasswordOpen.value = false;
|
||
resetPasswordForms();
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||
}
|
||
|
||
function rotateBroadcast() {
|
||
if (activeBroadcasts.value.length <= 1) {
|
||
return;
|
||
}
|
||
activeBroadcastIndex.value = (activeBroadcastIndex.value + 1) % activeBroadcasts.value.length;
|
||
}
|
||
|
||
async function loadResetPasswordUsers() {
|
||
resetPasswordUsersLoading.value = true;
|
||
try {
|
||
const rows = await fetchResource("/system-permissions/users", []);
|
||
resetPasswordUsers.value = Array.isArray(rows) ? rows : [];
|
||
} finally {
|
||
resetPasswordUsersLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function formatResetPasswordUserLabel(user) {
|
||
const roleText = Array.isArray(user?.role_names) && user.role_names.length ? user.role_names.join("、") : "未分配角色";
|
||
const statusText = user?.status === "ACTIVE" ? "启用" : "停用";
|
||
return [
|
||
user?.username || "未设置账号",
|
||
user?.employee_name || "未关联人员",
|
||
roleText,
|
||
statusText
|
||
].join(" · ");
|
||
}
|
||
|
||
function sendAssistantMessage() {
|
||
if (!chatInput.value) {
|
||
return;
|
||
}
|
||
const userText = chatInput.value;
|
||
chatMessages.value.push({
|
||
id: Date.now(),
|
||
role: "user",
|
||
content: userText
|
||
});
|
||
chatInput.value = "";
|
||
chatMessages.value.push({
|
||
id: Date.now() + 1,
|
||
role: "assistant",
|
||
content: "已收到。"
|
||
});
|
||
}
|
||
|
||
async function submitChangePassword() {
|
||
if (changePasswordForm.value.new_password !== changePasswordForm.value.confirm_password) {
|
||
notifyError("两次输入的新密码不一致");
|
||
return;
|
||
}
|
||
passwordSubmitting.value = true;
|
||
try {
|
||
const result = await postResource("/auth/password/change", {
|
||
old_password: changePasswordForm.value.old_password,
|
||
new_password: changePasswordForm.value.new_password
|
||
});
|
||
notifySuccess(result.message || "密码已修改");
|
||
closePasswordModals();
|
||
} catch (error) {
|
||
notifyError(error.message || "密码修改失败");
|
||
} finally {
|
||
passwordSubmitting.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitResetPassword() {
|
||
if (resetPasswordForm.value.new_password !== resetPasswordForm.value.confirm_password) {
|
||
notifyError("两次输入的新密码不一致");
|
||
return;
|
||
}
|
||
passwordSubmitting.value = true;
|
||
try {
|
||
const result = await postResource("/auth/password/reset", {
|
||
username: resetPasswordForm.value.username,
|
||
new_password: resetPasswordForm.value.new_password
|
||
});
|
||
notifySuccess(result.message || "密码已重置");
|
||
closePasswordModals();
|
||
} catch (error) {
|
||
notifyError(error.message || "密码重置失败");
|
||
} finally {
|
||
passwordSubmitting.value = false;
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
clearSession();
|
||
currentUser.value = null;
|
||
activeBroadcasts.value = [];
|
||
activeBroadcastIndex.value = 0;
|
||
router.push({ name: "login" });
|
||
}
|
||
</script>
|