生产模块拆分为前缫/后缫;修复菜单卡顿与重复请求;支持 IIS 单站点部署

1. 生产模块按工序拆分为两个一级菜单
   - 生产-前缫(落丝记录)、生产-后缫(纤度检验、黑板检验)
   - 旧的「生产管理」(/prod) 目录及子菜单由种子自动清理,页面组件与权限标识保持不变
   - 消息中心跳转路径、首页快捷入口路径同步更新

2. 职工管理独立为一级菜单(原挂在「基础数据」目录下),并清理历史子菜单

3. 修复侧边栏菜单展开卡顿(基础数据等子项较多的目录尤其明显)
   - 去掉 Element Plus 的 height 过渡动画(会逐帧重排全部子项)
   - 收起时改用 height:0 + visibility:hidden 而非 display:none,子项提前完成布局
   - 菜单区独立滚动并预留滚动条宽度(scrollbar-gutter),消除展开时的横向抖动
   - 菜单项增加 contain 布局隔离,淡入动画改为容器级

4. 修复页面首次打开重复请求
   - 分页组件挂载时会触发一次 current-change,页码未变化时不再重复加载(18 个页面)
   - 职工管理去掉 onMounted 与默认选中部门的重复加载
   - 工种管理、原料庄口的注册表下拉由按 key 多次请求改为一次全量拉取

5. 后端增加前端静态托管与 SPA 路由回退,支持 IIS 单站点部署(前后端同源)
6. 新增部署脚本 deploy-iis.ps1(首次部署)与 update-iis.ps1(更新发布)
This commit is contained in:
yanpeng200212
2026-10-12 14:09:59 +08:00
parent 2ffb83830c
commit c3e2665257
47 changed files with 59270 additions and 109 deletions
+119 -37
View File
@@ -87,43 +87,45 @@ async function handleLogout() {
<el-icon class="logo-icon"><MagicStick /></el-icon>
<span v-if="!isCollapse" class="logo-text">智慧缫丝系统</span>
</div>
<el-menu
:default-active="route.path"
:collapse="isCollapse"
background-color="#0c3b2e"
text-color="#b5cdc2"
active-text-color="#e8c877"
router
class="menu"
>
<el-menu-item index="/dashboard">
<el-icon><HomeFilled /></el-icon>
<template #title>首页</template>
</el-menu-item>
<template v-for="menu in sidebarMenus" :key="menu.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="menu.children && menu.children.length > 0" :index="menu.path">
<template #title>
<el-icon><component :is="menu.icon || 'Menu'" /></el-icon>
<span>{{ menu.name }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.id"
:index="child.path"
>
<el-icon><component :is="child.icon || 'Document'" /></el-icon>
<template #title>{{ child.name }}</template>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="menu.path">
<el-icon><component :is="menu.icon || 'Menu'" /></el-icon>
<template #title>{{ menu.name }}</template>
<div class="menu-wrapper">
<el-menu
:default-active="route.path"
:collapse="isCollapse"
background-color="#0c3b2e"
text-color="#b5cdc2"
active-text-color="#e8c877"
router
class="menu"
>
<el-menu-item index="/dashboard">
<el-icon><HomeFilled /></el-icon>
<template #title>首页</template>
</el-menu-item>
</template>
</el-menu>
<template v-for="menu in sidebarMenus" :key="menu.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="menu.children && menu.children.length > 0" :index="menu.path">
<template #title>
<el-icon><component :is="menu.icon || 'Menu'" /></el-icon>
<span>{{ menu.name }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.id"
:index="child.path"
>
<el-icon><component :is="child.icon || 'Document'" /></el-icon>
<template #title>{{ child.name }}</template>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="menu.path">
<el-icon><component :is="menu.icon || 'Menu'" /></el-icon>
<template #title>{{ menu.name }}</template>
</el-menu-item>
</template>
</el-menu>
</div>
</aside>
<!-- 主区域 -->
@@ -187,17 +189,95 @@ async function handleLogout() {
}
.sidebar {
display: flex;
flex-direction: column;
width: 220px;
background: linear-gradient(180deg, #0f4a38 0%, #0c3b2e 100%);
transition: width 0.3s;
overflow-x: hidden;
overflow: hidden;
}
.sidebar.collapse {
width: 64px;
}
/* 折叠态下图标居中优先,不预留滚动槽(此时子菜单以浮层弹出,高度不变化,不会抖动) */
.sidebar.collapse .menu-wrapper {
scrollbar-gutter: auto;
}
/* 菜单区独立滚动,并始终预留滚动条宽度(scrollbar-gutter: stable):
否则展开"基础数据"这类子项较多的目录时,总高度跨过视口,滚动条突然出现/消失,
会把菜单横向挤压位移,视觉上就是一卡一卡 */
.menu-wrapper {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.menu-wrapper::-webkit-scrollbar {
width: 6px;
}
.menu-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.menu-wrapper::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 3px;
}
.menu-wrapper::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.32);
}
/* Element Plus 默认用 transition: height 做展开动画,而 height 过渡不是合成层动画:
浏览器每一帧都要重新布局容器内的**全部**子菜单项,子项多时(如"基础数据"10 项)
在普通机器上会明显掉帧(实测单帧可达 120ms+)。
这里去掉 height 过渡,改用只涉及合成层的 opacity + transform 动画,视觉一样但不触发重排。 */
.menu-wrapper :deep(.el-collapse-transition-enter-active),
.menu-wrapper :deep(.el-collapse-transition-leave-active) {
transition: none !important;
}
/* 淡入只加在整个子菜单容器上(只产生一个合成层),
若加在每个子项上,会为 10 个元素分别创建合成层,展开瞬间反而更慢 */
.menu-wrapper :deep(.el-menu--inline) {
animation: menu-submenu-in 0.16s ease-out;
}
/* 布局/绘制隔离:展开时把每个菜单项的重排限制在自身内部,避免整块重排 */
.menu-wrapper :deep(.el-menu-item),
.menu-wrapper :deep(.el-sub-menu__title) {
contain: layout paint;
}
/* 收起时不要用 display:none(Element Plus 默认行为),而是用 height:0 + 隐藏裁剪:
这样子项在页面加载时就已完成布局,展开瞬间无需对全部子项做首次布局,
这正是"子项越多越卡"(基础数据 10 项)的根源所在 */
.menu-wrapper :deep(.el-sub-menu:not(.is-opened) > .el-menu--inline) {
display: block !important;
height: 0 !important;
overflow: hidden;
visibility: hidden;
}
@keyframes menu-submenu-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.logo {
flex: none;
height: 60px;
display: flex;
align-items: center;
@@ -294,6 +374,8 @@ async function handleLogout() {
flex: 1;
padding: 20px;
overflow-y: auto;
/* 内容区同样预留滚动条宽度,避免切换页面时宽度跳动 */
scrollbar-gutter: stable;
background: #f0f2f5;
}
</style>
+1
View File
@@ -58,6 +58,7 @@ export const componentMap = {
'base/yuanliaozhuangkou/index': () => import('../views/base/yuanliaozhuangkou/index.vue'),
'base/wuzimulu/index': () => import('../views/base/wuzimulu/index.vue'),
'base/gongyingshang/index': () => import('../views/base/gongyingshang/index.vue'),
'base/employee/index': () => import('../views/base/employee/index.vue'),
'prod/lsjl/index': () => import('../views/prod/lsjl/index.vue'),
'prod/xdjy/index': () => import('../views/prod/xdjy/index.vue'),
'prod/hbjy/index': () => import('../views/prod/hbjy/index.vue'),
+2
View File
@@ -36,6 +36,8 @@ function statusType(status) {
}
function handlePageChange(page) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (page === pageIndex.value) return;
pageIndex.value = page;
loadRecords();
}
@@ -43,6 +43,8 @@ function resetSearch() {
}
function handlePageChange(page) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (page === query.pageIndex) return;
query.pageIndex = page;
load();
}
+6 -1
View File
@@ -79,7 +79,12 @@ async function del(row) {
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
onMounted(() => { load(); loadGuige(); });
</script>
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -43,6 +43,13 @@ const form = reactive({
const vendorGroupTypes = [3, 4]; // 副产品 / 生丝 → 默认分组为"客户"
// 分页组件挂载时会触发一次 current-change:页码未变化时直接忽略,避免页面首次打开重复请求
function handlePageChange(p) {
if (p === query.pageIndex) return;
query.pageIndex = p;
loadList();
}
async function loadList() {
loading.value = true;
try {
@@ -190,12 +197,12 @@ onMounted(loadList);
</el-table>
<el-pagination
v-model:current-page="query.pageIndex"
v-model:page-size="query.pageSize"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="loadList"
@current-change="handlePageChange"
/>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
+6 -1
View File
@@ -73,7 +73,12 @@ async function del(row) {
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
onMounted(load);
</script>
+6 -1
View File
@@ -71,7 +71,12 @@ async function del(row) {
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
onMounted(() => { load(); loadDepts(); });
</script>
+18 -9
View File
@@ -13,18 +13,20 @@ const query = reactive({ pageIndex: 1, pageSize: 20, keyword: '' });
const bizTypeOptions = ref(['生产', '辅助']);
const sccbFenleiOptions = ref(['无', '管理']);
// 一次拉取注册表全量后本地取用,避免每个键各发一次请求
async function loadRegistryOptions() {
try {
const load = async (keyName) => {
const res = await request.get('/sys/registry/by-key', {
params: { category: '基础数据\\工种', keyName }
});
return (res.data?.value || '')
const res = await request.get('/sys/registry');
const all = res.data || [];
const pick = (keyName) => {
const hit = all.find((x) => x.category === '基础数据\\工种' && x.keyName === keyName);
return (hit?.value || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
};
const [biz, fenlei] = await Promise.all([load('业务类型'), load('生产成本分类')]);
const biz = pick('业务类型');
const fenlei = pick('生产成本分类');
if (biz.length) bizTypeOptions.value = biz;
if (fenlei.length) sccbFenleiOptions.value = fenlei;
} catch (e) {
@@ -52,6 +54,13 @@ const boundRoleIds = ref([]);
const currentRow = ref(null);
// 分页组件挂载时会触发一次 current-change:页码未变化时直接忽略,避免页面首次打开重复请求
function handlePageChange(p) {
if (p === query.pageIndex) return;
query.pageIndex = p;
loadList();
}
async function loadList() {
loading.value = true;
try {
@@ -255,12 +264,12 @@ onMounted(() => {
</el-table>
<el-pagination
v-model:current-page="query.pageIndex"
v-model:page-size="query.pageSize"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="loadList"
@current-change="handlePageChange"
/>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="460px">
+10 -3
View File
@@ -120,6 +120,13 @@ function handleNodeClick(node) {
loadMulu();
}
// 分页组件挂载时会触发一次 current-change:页码未变化时直接忽略,避免页面首次打开重复请求
function handlePageChange(p) {
if (p === query.pageIndex) return;
query.pageIndex = p;
loadMulu();
}
async function loadMulu() {
loading.value = true;
try {
@@ -397,12 +404,12 @@ onMounted(loadGroups);
</el-table>
<el-pagination
v-model:current-page="query.pageIndex"
v-model:page-size="query.pageSize"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="loadMulu"
@current-change="handlePageChange"
/>
</div>
</div>
+24 -22
View File
@@ -49,27 +49,22 @@ function bendiwaidiLabel(v) {
return hit ? hit.label : v;
}
// 一次拉取注册表全量后本地取用,避免每个键各发一次请求
async function loadRegistryOptions() {
const load = async (keyName) => {
const res = await request.get('/sys/registry/by-key', {
params: { category: '基础数据\\原料庄口', keyName }
});
return (res.data?.value || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
};
try {
const [chandi, zhandian, jibie, canpinzhong] = await Promise.all([
load('产地'),
load('站点'),
load('季别'),
load('蚕品种')
]);
registryOptions.chandi = chandi;
registryOptions.zhandian = zhandian;
registryOptions.jibie = jibie;
registryOptions.canpinzhong = canpinzhong;
const res = await request.get('/sys/registry');
const all = res.data || [];
const pick = (keyName) => {
const hit = all.find((x) => x.category === '基础数据\\原料庄口' && x.keyName === keyName);
return (hit?.value || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
};
registryOptions.chandi = pick('产地');
registryOptions.zhandian = pick('站点');
registryOptions.jibie = pick('季别');
registryOptions.canpinzhong = pick('蚕品种');
} catch (e) {
// 注册表未配置时下拉为空,仍可手工录入
}
@@ -82,6 +77,13 @@ async function loadWuziOptions() {
} catch (e) {}
}
// 分页组件挂载时会触发一次 current-change:页码未变化时直接忽略,避免页面首次打开重复请求
function handlePageChange(p) {
if (p === query.pageIndex) return;
query.pageIndex = p;
loadList();
}
async function loadList() {
loading.value = true;
try {
@@ -274,12 +276,12 @@ onMounted(async () => {
</el-table>
<el-pagination
v-model:current-page="query.pageIndex"
v-model:page-size="query.pageSize"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="loadList"
@current-change="handlePageChange"
/>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
+10 -3
View File
@@ -48,6 +48,13 @@ function defaultRange() {
return { start: str(start), end: str(end) };
}
// 分页组件挂载时会触发一次 current-change:页码未变化时直接忽略,避免页面首次打开重复请求
function handlePageChange(p) {
if (p === query.pageIndex) return;
query.pageIndex = p;
loadList();
}
async function loadList() {
loading.value = true;
try {
@@ -205,12 +212,12 @@ onMounted(loadList);
</el-table>
<el-pagination
v-model:current-page="query.pageIndex"
v-model:page-size="query.pageSize"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="loadList"
@current-change="handlePageChange"
/>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px">
+6 -1
View File
@@ -80,7 +80,12 @@ async function del(row) {
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
onMounted(load);
</script>
+3 -3
View File
@@ -7,9 +7,9 @@ const userStore = useUserStore();
const user = userStore.userInfo || {};
const quickLinks = [
{ title: '落丝记录', desc: '缫丝生产 · 落丝管理', icon: 'DataLine', path: '/prod/lsjl', color: '#1e6b52' },
{ title: '纤度检验', desc: '纤度 / 野纤等级判定', icon: 'ScaleToOriginal', path: '/prod/xdjy', color: '#c9a54a' },
{ title: '黑板检验', desc: '清洁 / 洁净等级', icon: 'Grid', path: '/prod/hbjy', color: '#3f8f6e' },
{ title: '落丝记录', desc: '前缫 · 落丝管理', icon: 'DataLine', path: '/prod/qianxian/lsjl', color: '#1e6b52' },
{ title: '纤度检验', desc: '后缫 · 纤度 / 野纤等级判定', icon: 'ScaleToOriginal', path: '/prod/houxian/xdjy', color: '#c9a54a' },
{ title: '黑板检验', desc: '后缫 · 清洁 / 洁净等级', icon: 'Grid', path: '/prod/houxian/hbjy', color: '#3f8f6e' },
{ title: '考勤打卡', desc: '上下班签到签退', icon: 'AlarmClock', path: '/attendance/punch', color: '#b8873a' }
];
@@ -64,7 +64,12 @@ async function markAllRead() {
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
function resetPage() { query.pageIndex = 1; load(); }
function rowClass({ row }) {
+6 -1
View File
@@ -31,7 +31,12 @@ async function load() {
}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
// ==================== 编辑对话框 ====================
const dialogVisible = ref(false);
+6 -1
View File
@@ -124,7 +124,12 @@ async function del(row) {
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
onMounted(() => { load(); loadDepts(); loadZhuangkou(); loadGuige(); });
</script>
+6 -1
View File
@@ -32,7 +32,12 @@ async function load() {
}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function handlePageChange(p) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (p === query.pageIndex) return;
query.pageIndex = p;
load();
}
// ==================== 编辑对话框 ====================
const dialogVisible = ref(false);
+6 -1
View File
@@ -138,7 +138,12 @@ async function handleSaveRoles() {
} catch (e) {}
}
function handlePageChange(page) { query.page = page; loadList(); }
function handlePageChange(page) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (page === query.page) return;
query.page = page;
loadList();
}
onMounted(() => { loadList(); loadDepts(); loadRoles(); });
</script>
+2
View File
@@ -128,6 +128,8 @@ async function handleResetPwd(row) {
}
function handlePageChange(page) {
// 分页组件挂载时会触发一次 current-change,页码未变化时忽略,避免首次打开重复请求
if (page === query.pageIndex) return;
query.pageIndex = page;
loadList();
}