ForgeFlow-ERP/frontend/src/App.vue
2026-06-15 12:06:46 +08:00

1255 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>