first commit

This commit is contained in:
yanpeng200212
2026-10-08 10:43:59 +08:00
commit 8430ba0452
288 changed files with 29595 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智慧缫丝系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
{
"runtime": "Net80",
"defaultVariables": null,
"documentGenerator": {
"fromDocument": {
"url": "http://localhost:3000/swagger/v1/swagger.json",
"output": null,
"newLineBehavior": "Auto"
}
},
"codeGenerators": {
"openApiToTypeScriptClient": {
"className": "ApiClient",
"moduleName": "",
"namespace": "",
"typeScriptVersion": 4.3,
"template": "Axios",
"promiseType": "Promise",
"httpClass": "HttpClient",
"withCredentials": false,
"useSingletonProvider": false,
"injectionTokenType": "OpaqueToken",
"rxJsVersion": 6.0,
"dateTimeType": "Date",
"nullValue": "Undefined",
"generateClientClasses": true,
"generateClientInterfaces": false,
"generateOptionalParameters": false,
"exportTypes": true,
"wrapDtoExceptions": false,
"exceptionClass": "ApiException",
"clientBaseClass": null,
"wrapResponses": false,
"wrapResponseMethods": [],
"generateResponseClasses": true,
"responseClass": "SwaggerResponse",
"protectedMethods": [],
"configurationClass": null,
"useTransformOptionsMethod": false,
"useTransformResultMethod": false,
"generateDtoTypes": true,
"operationGenerationMode": "MultipleClientsFromOperationId",
"markOptionalProperties": true,
"generateCloneMethod": false,
"typeStyle": "Interface",
"enumStyle": "Enum",
"useLeafType": false,
"classTypes": [],
"extendedClasses": [],
"extensionCode": null,
"generateDefaultValues": true,
"excludedTypeNames": [],
"excludedParameterNames": [],
"handleReferences": false,
"generateTypeCheckFunctions": false,
"generateConstructorInterface": true,
"convertConstructorInterfaceData": false,
"importRequiredTypes": true,
"useGetBaseUrlMethod": false,
"baseUrlTokenName": "API_BASE_URL",
"queryNullValue": "",
"useAbortSignal": false,
"inlineNamedDictionaries": false,
"inlineNamedAny": false,
"includeHttpContext": false,
"templateDirectory": null,
"serviceHost": null,
"serviceSchemes": null,
"output": "src/api/client.ts"
}
}
}
+1862
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "f9-web-ai-test",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"gen:api": "nswag run nswag.json"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@microsoft/signalr": "^10.0.11",
"axios": "^1.7.7",
"element-plus": "^2.8.4",
"pinia": "^2.2.2",
"vue": "^3.4.38",
"vue-router": "^4.4.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.3",
"vite": "^5.4.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import { useUserStore } from '../store/user';
// 权限判断工具函数
export function hasPermission(value) {
const userStore = useUserStore();
const permissions = userStore.permissions || [];
// admin 角色拥有所有权限
const roles = userStore.userInfo?.roles || [];
if (roles.includes('admin')) return true;
if (Array.isArray(value)) {
return value.some((p) => permissions.includes(p));
}
return permissions.includes(value);
}
// 权限指令 v-permission="'system:user:add'"
export default {
mounted(el, binding) {
if (!hasPermission(binding.value)) {
el.parentNode && el.parentNode.removeChild(el);
}
}
};
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+299
View File
@@ -0,0 +1,299 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserStore } from '../store/user';
import { resetRoutes } from '../router';
import { ElMessageBox, ElNotification } from 'element-plus';
import { initNotification, stopNotification, unreadCount, loadUnreadCount, openMessage } from '../utils/notification';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const isCollapse = ref(false);
const sidebarMenus = ref([]);
// 面包屑
const breadcrumbs = computed(() => {
const matched = route.matched.filter((item) => item.meta && item.meta.title);
return matched;
});
// 构建菜单树(目录 + 菜单)
function buildMenuTree(menus) {
return menus.filter((m) => m.type === 'directory' || m.type === 'menu');
}
// 实时收到新消息:弹窗提示(未读数量由 notification.js 统一 +1)
function handleMessage(msg) {
const p = ['普通', '重要', '紧急'][msg.priority] || '普通';
const type = msg.priority === 2 ? 'error' : (msg.priority === 1 ? 'warning' : 'info');
const isTodo = msg.category === 1;
ElNotification({
title: `【${p}】${isTodo ? '待办' : ''}${msg.typeName || '新消息'}`,
message: msg.title,
type,
duration: 5000,
onClick: () => {
// 有业务跳转则跳业务页,否则跳消息中心
if (!msg.routePath) {
router.push('/notification/messages');
return;
}
openMessage(msg, router);
}
});
}
function goMessages() {
router.push('/notification/messages');
}
onMounted(async () => {
// 菜单已在路由守卫中加载并缓存到 store,这里直接使用
if (userStore.menus.length === 0) {
sidebarMenus.value = buildMenuTree(await userStore.fetchMenus());
} else {
sidebarMenus.value = buildMenuTree(userStore.menus);
}
// 全局初始化 SignalR 通知连接 + 加载未读数量
loadUnreadCount();
initNotification(handleMessage);
});
onBeforeUnmount(() => {
// 布局不卸载,这里仅作保险(登出时由 logout 触发 stopNotification)
});
function toggleCollapse() {
isCollapse.value = !isCollapse.value;
}
async function handleLogout() {
await ElMessageBox.confirm('确定退出登录吗?', '提示', { type: 'warning' });
stopNotification();
userStore.logout();
resetRoutes();
router.push('/login');
}
</script>
<template>
<div class="layout">
<!-- 侧边栏 -->
<aside class="sidebar" :class="{ collapse: isCollapse }">
<div class="logo">
<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>
</el-menu-item>
</template>
</el-menu>
</aside>
<!-- 主区域 -->
<div class="main-container">
<!-- 顶部栏 -->
<header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="toggleCollapse">
<Expand v-if="isCollapse" />
<Fold v-else />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="item in breadcrumbs"
:key="item.path"
:to="item.path ? { path: item.path } : undefined"
>
{{ item.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 消息中心铃铛(头像左侧) -->
<el-tooltip content="消息中心" placement="bottom">
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99" class="notify-badge">
<el-icon class="notify-icon" @click="goMessages"><Bell /></el-icon>
</el-badge>
</el-tooltip>
<el-dropdown @command="handleLogout">
<span class="user-info">
<el-avatar :size="30" class="avatar">
{{ (userStore.userInfo?.nickname || userStore.userInfo?.realName || 'U').charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.userInfo?.nickname || userStore.userInfo?.realName }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容区 -->
<main class="content">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
.layout {
display: flex;
height: 100%;
}
.sidebar {
width: 220px;
background: linear-gradient(180deg, #0f4a38 0%, #0c3b2e 100%);
transition: width 0.3s;
overflow-x: hidden;
}
.sidebar.collapse {
width: 64px;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff;
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.logo-icon {
color: #e8c877;
}
.logo-text {
white-space: nowrap;
}
.menu {
border-right: none;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.header {
height: 60px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.notify-badge {
display: flex;
align-items: center;
}
.notify-icon {
font-size: 20px;
cursor: pointer;
color: #555;
transition: color 0.2s;
}
.notify-icon:hover {
color: #16a34a;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #333;
}
.avatar {
background: #16a34a;
color: #fff;
font-weight: 600;
}
.username {
font-size: 14px;
}
.content {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f0f2f5;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import App from './App.vue';
import router, { buildRoutes, markRoutesLoaded } from './router';
import permissionDirective from './directives/permission';
import { getAccessToken } from './utils/request';
import request from './utils/request';
import './styles/index.css';
import './styles/theme.css';
const app = createApp(App);
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 注册权限指令
app.directive('permission', permissionDirective);
app.use(createPinia());
// 在挂载前同步初始化动态路由(避免 "No match" 警告)
async function bootstrap() {
if (getAccessToken()) {
try {
const [menusRes, permsRes] = await Promise.all([
request.get('/auth/menus'),
request.get('/auth/permissions')
]);
const routes = buildRoutes(menusRes.data);
routes.forEach((r) => {
if (!router.hasRoute(r.name)) {
router.addRoute('Layout', r);
}
});
// 缓存到 store
const { useUserStore } = await import('./store/user');
const store = useUserStore();
store.menus = menusRes.data;
store.permissions = permsRes.data;
markRoutesLoaded();
} catch (e) {
// 菜单加载失败,可能是 token 失效,交给路由守卫跳登录
}
}
app.use(router);
app.use(ElementPlus, { locale: zhCn });
app.mount('#app');
}
bootstrap();
+169
View File
@@ -0,0 +1,169 @@
import { createRouter, createWebHistory } from 'vue-router';
import { getAccessToken } from '../utils/request';
import request from '../utils/request';
// 静态路由
export const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/login/index.vue'),
meta: { title: '登录', hidden: true }
},
{
path: '/',
name: 'Layout',
component: () => import('../layout/index.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/dashboard/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
},
// 请假审批:静态注册,所有登录用户可访问(待办消息"去处理"跳转入口,不依赖菜单权限)
{
path: 'attendance/approve',
name: 'AttendanceApprove',
component: () => import('../views/attendance/approve/index.vue'),
meta: { title: '请假审批', icon: 'Checked' }
}
]
}
];
// 已静态注册的路由(不随菜单动态注册,避免重复)
const STATIC_ROUTES = ['/attendance/approve'];
// 组件映射:后端菜单的 component 字段 -> 前端组件
export const componentMap = {
'system/department/index': () => import('../views/system/department/index.vue'),
'system/employee/index': () => import('../views/system/employee/index.vue'),
'system/role/index': () => import('../views/system/role/index.vue'),
'system/menu/index': () => import('../views/system/menu/index.vue'),
'system/log/index': () => import('../views/system/log/index.vue'),
'attendance/punch/index': () => import('../views/attendance/punch/index.vue'),
'attendance/my/index': () => import('../views/attendance/my/index.vue'),
'attendance/leave/index': () => import('../views/attendance/leave/index.vue'),
'attendance/records/index': () => import('../views/attendance/records/index.vue'),
'attendance/approve/index': () => import('../views/attendance/approve/index.vue'),
'base/zhuangkou/index': () => import('../views/base/zhuangkou/index.vue'),
'base/guige/index': () => import('../views/base/guige/index.vue'),
'base/dingjizhibiao/index': () => import('../views/base/dingjizhibiao/index.vue'),
'base/jizuchetai/index': () => import('../views/base/jizuchetai/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'),
'notification/messages/index': () => import('../views/notification/messages/index.vue'),
'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'),
'notification/type/index': () => import('../views/notification/type/index.vue'),
'sys/user/index': () => import('../views/sys/user/index.vue'),
'sys/role/index': () => import('../views/sys/role/index.vue'),
'sys/menu/index': () => import('../views/sys/menu/index.vue'),
'sys/department/index': () => import('../views/sys/department/index.vue')
};
// 空壳布局组件(用于目录节点,透传子路由)
const EmptyLayout = () => import('../layout/EmptyLayout.vue');
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes
});
// 白名单
const whiteList = ['/login'];
// 动态路由是否已注册
let routesLoaded = false;
// 标记路由已加载(main.js bootstrap 调用)
export function markRoutesLoaded() {
routesLoaded = true;
}
router.beforeEach(async (to, from, next) => {
const token = getAccessToken();
if (token) {
if (to.path === '/login') {
next({ path: '/' });
return;
}
// 兜底:如果 bootstrap 未注册路由(比如登录后首次跳转),这里加载
if (!routesLoaded) {
try {
const [menusRes, permsRes] = await Promise.all([
request.get('/auth/menus'),
request.get('/auth/permissions')
]);
const menus = menusRes.data;
const routes = buildRoutes(menus);
routes.forEach((r) => {
if (!router.hasRoute(r.name)) {
router.addRoute('Layout', r);
}
});
const { useUserStore } = await import('../store/user');
const store = useUserStore();
store.menus = menus;
store.permissions = permsRes.data;
routesLoaded = true;
// 重新导航到目标路由(此时路由已注册)
next({ ...to, replace: true });
return;
} catch (e) {
next('/login');
return;
}
}
next();
} else {
if (whiteList.includes(to.path)) {
next();
} else {
next(`/login?redirect=${to.path}`);
}
}
});
// 重置路由加载状态(退出登录时调用)
export function resetRoutes() {
routesLoaded = false;
}
// 将后端菜单树转换为 Vue Router 路由(平铺注册到 Layout 下,用绝对路径)
export function buildRoutes(menus) {
const routes = [];
const walk = (list) => {
list.forEach((menu) => {
// 只处理目录和菜单类型(按钮不生成路由)
if (menu.type === 'directory') {
routes.push({
path: menu.path,
name: menu.path.replace(/\//g, '-') + '-' + menu.id,
component: EmptyLayout,
meta: { title: menu.name, icon: menu.icon }
});
} else if (menu.type === 'menu') {
// 已静态注册的路由跳过,避免重复
if (STATIC_ROUTES.includes(menu.path)) return;
routes.push({
path: menu.path,
name: menu.path.replace(/\//g, '-') + '-' + menu.id,
component: componentMap[menu.component] || (() => import('../views/system/empty.vue')),
meta: { title: menu.name, icon: menu.icon }
});
}
// 递归处理子节点(平铺)
if (menu.children && menu.children.length > 0) {
walk(menu.children);
}
});
};
walk(menus);
return routes;
}
export default router;
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia';
import request, { getAccessToken, setTokens, clearTokens } from '../utils/request';
export const useUserStore = defineStore('user', {
state: () => ({
token: getAccessToken() || '',
userInfo: JSON.parse(localStorage.getItem('user') || 'null'),
menus: [],
permissions: []
}),
getters: {
isLoggedIn: (state) => !!state.token
},
actions: {
async login(loginForm) {
const res = await request.post('/auth/login', loginForm);
const { accessToken, refreshToken, user } = res.data;
setTokens(accessToken, refreshToken);
this.token = accessToken;
this.userInfo = user;
localStorage.setItem('user', JSON.stringify(user));
return res;
},
async fetchMenus() {
const res = await request.get('/auth/menus');
this.menus = res.data;
return res.data;
},
async fetchPermissions() {
const res = await request.get('/auth/permissions');
this.permissions = res.data;
return res.data;
},
logout() {
clearTokens();
this.token = '';
this.userInfo = null;
this.menus = [];
this.permissions = [];
localStorage.removeItem('user');
}
}
});
+20
View File
@@ -0,0 +1,20 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
body {
background: #f6f4ef;
-webkit-font-smoothing: antialiased;
}
a {
text-decoration: none;
color: inherit;
}
+33
View File
@@ -0,0 +1,33 @@
/* ============================================================
智慧缫丝系统主题
蚕桑黛绿(沉稳专业)+ 丝绸金(光泽点缀)+ 暖白(丝绸质感)
============================================================ */
:root {
/* Element Plus 主色改为黛绿 */
--el-color-primary: #1e6b52;
--el-color-primary-light-3: #4d8f74;
--el-color-primary-light-5: #82b39d;
--el-color-primary-light-7: #bad5c8;
--el-color-primary-light-8: #d6e7de;
--el-color-primary-light-9: #ecf4f0;
--el-color-primary-dark-2: #175544;
/* 成功色统一为黛绿系 */
--el-color-success: #1e6b52;
--el-color-success-light-3: #4d8f74;
--el-color-success-light-5: #82b39d;
--el-color-success-light-7: #bad5c8;
--el-color-success-light-8: #d6e7de;
--el-color-success-light-9: #ecf4f0;
--el-color-success-dark-2: #175544;
/* 自定义全局变量 */
--lc-primary: #1e6b52; /* 主色:黛绿 */
--lc-primary-dark: #175544; /* 深黛绿 */
--lc-primary-deep: #0c3b2e; /* 墨绿(侧边栏) */
--lc-accent: #c9a54a; /* 丝绸金 */
--lc-accent-light: #e8c877;
--lc-bg: #f6f4ef; /* 暖白背景(丝绸质感) */
--lc-text: #2c3a35; /* 主文字 */
--lc-text-secondary: #6b7a74; /* 次要文字 */
}
+14
View File
@@ -0,0 +1,14 @@
// 新部门接口(/sys/department)返回扁平列表,这里转换为 el-tree 所需树结构
export function buildDeptTree(list) {
const map = {};
(list || []).forEach((d) => {
map[d.id] = { id: d.id, name: d.name, code: d.code, parentId: d.parentId, children: [] };
});
const roots = [];
(list || []).forEach((d) => {
const node = map[d.id];
if (d.parentId && map[d.parentId]) map[d.parentId].children.push(node);
else roots.push(node);
});
return roots;
}
+99
View File
@@ -0,0 +1,99 @@
import { ref } from 'vue';
import * as signalR from '@microsoft/signalr';
import { getAccessToken } from './request';
import request from './request';
// 全局未读数量(layout 徽章 与 消息页共用)
export const unreadCount = ref(0);
let connection = null;
const messageHandlers = [];
const businessChangeHandlers = [];
/** 加载未读数量 */
export async function loadUnreadCount() {
try {
const res = await request.get('/notification/unread-count');
unreadCount.value = res.data.count;
} catch (e) {}
}
/**
* 初始化 SignalR 通知连接(幂等,重复调用返回同一连接)
* @param {Function} onMessage 收到新消息时的回调 (msg) => void
*/
export function initNotification(onMessage) {
if (connection) {
if (onMessage) messageHandlers.push(onMessage);
return connection;
}
if (onMessage) messageHandlers.push(onMessage);
connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notification', {
accessTokenFactory: () => getAccessToken() || ''
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
.build();
connection.on('ReceiveNotification', (msg) => {
unreadCount.value++; // 全局未读 +1
messageHandlers.forEach((h) => { try { h(msg); } catch (e) {} });
});
// 业务状态变更事件(多端同步:某端操作后,其它端刷新已打开的页面)
connection.on('BusinessChanged', (e) => {
businessChangeHandlers.forEach((h) => { try { h(e); } catch (err) {} });
});
connection.start().catch(() => {});
return connection;
}
/**
* 订阅业务状态变更事件(用于多端同步刷新)。
* @param {Function} handler (e) => void,e 含 businessType / businessId / action
*/
export function onBusinessChanged(handler) {
if (handler) businessChangeHandlers.push(handler);
initNotification(); // 确保连接已建立(幂等)
}
/**
* 打开消息:标记已读 + 跳转到业务页面(消息自带 routePath 时)。
* @param {Object} msg 消息对象(含 id/readStatus/routePath/category)
* @param {Object} router vue-router 实例
* @returns {boolean} 是否发生了页面跳转
*/
export async function openMessage(msg, router) {
// 待办消息(category=1)点击"去处理"不标记已读,保持未读提醒;
// 只有真正完成待办后(后端完成待办时会标记已读)才消失
if (msg.readStatus === 0 && msg.category !== 1) {
try {
await request.put(`/notification/messages/${msg.id}/read`);
msg.readStatus = 1;
loadUnreadCount();
} catch (e) {}
}
if (msg.routePath) {
router.push(msg.routePath);
return true;
}
return false;
}
/** 停止连接(登出时调用) */
export function stopNotification() {
if (connection) {
connection.stop().catch(() => {});
connection = null;
}
messageHandlers.length = 0;
unreadCount.value = 0;
}
export function getNotificationConnection() {
return connection;
}
+112
View File
@@ -0,0 +1,112 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
const request = axios.create({
baseURL: '/api',
timeout: 15000
});
// 令牌存取
const TOKEN_KEY = 'access_token';
const REFRESH_KEY = 'refresh_token';
export function getAccessToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setTokens(accessToken, refreshToken) {
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_KEY, refreshToken);
}
export function clearTokens() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_KEY);
}
// 请求拦截:附加 access token
request.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 刷新令牌(避免并发重复刷新)
let isRefreshing = false;
let pendingQueue = [];
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) return null;
try {
const res = await axios.post('/api/auth/refresh', { refreshToken });
if (res.data?.code === 200) {
setTokens(res.data.data.accessToken, res.data.data.refreshToken);
return res.data.data.accessToken;
}
return null;
} catch (e) {
return null;
}
}
// 响应拦截
request.interceptors.response.use(
(res) => {
const data = res.data;
if (data.code === 200) {
return data;
}
ElMessage.error(data.message || '请求失败');
return Promise.reject(new Error(data.message || '请求失败'));
},
async (err) => {
const { response, config } = err;
// 401:尝试用 refresh token 刷新 access token 后重试
if (response?.status === 401 && !config._retry && getRefreshToken()) {
if (isRefreshing) {
// 等待正在进行的刷新完成
return new Promise((resolve) => {
pendingQueue.push((newToken) => {
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(request(config));
} else {
resolve(Promise.reject(err));
}
});
});
}
config._retry = true;
isRefreshing = true;
const newToken = await refreshAccessToken();
isRefreshing = false;
if (newToken) {
pendingQueue.forEach((cb) => cb(newToken));
pendingQueue = [];
config.headers.Authorization = `Bearer ${newToken}`;
return request(config);
} else {
pendingQueue.forEach((cb) => cb(null));
pendingQueue = [];
clearTokens();
localStorage.removeItem('user');
ElMessage.error('登录已过期,请重新登录');
window.location.href = '/login';
return Promise.reject(err);
}
}
const msg = response?.data?.message || '请求失败,请稍后重试';
ElMessage.error(msg);
return Promise.reject(err);
}
);
export default request;
+154
View File
@@ -0,0 +1,154 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useUserStore } from '../../../store/user';
import request from '../../../utils/request';
import { onBusinessChanged, loadUnreadCount } from '../../../utils/notification';
import { ElMessage, ElMessageBox } from 'element-plus';
const route = useRoute();
const userStore = useUserStore();
const leaves = ref([]);
const loading = ref(false);
// 带 leaveId 跳转时默认显示"全部",确保能看到并高亮目标单
const statusFilter = ref(route.query.leaveId ? '全部' : '待审批');
// 从消息跳转带来的 leaveId,用于高亮定位对应申请单
const highlightId = ref(route.query.leaveId || '');
// 当前登录用户ID
const currentUserId = userStore.userInfo?.id;
// 当前登录用户是否为该申请单的当前审批人(按序号定位)
function isCurrentApprover(row) {
if (!currentUserId) return false;
const current = row.approvers?.find(a => a.orderNo === row.currentOrderNo);
return current && current.approverId === currentUserId;
}
async function load() {
loading.value = true;
try {
const res = await request.get('/attendance/leave', { params: { status: statusFilter.value } });
leaves.value = res.data;
} finally {
loading.value = false;
}
}
function rowClass({ row }) {
return highlightId.value && String(row.id) === String(highlightId.value) ? 'row-highlight' : '';
}
async function approve(row, approved) {
const action = approved ? '批准' : '驳回';
const { value } = await ElMessageBox.prompt(`确认${action}该请假申请?可填写审批意见`, `${action}确认`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '审批意见(可选)'
});
try {
await request.put(`/attendance/leave/${row.id}/approve`, { approved, remark: value || '' });
ElMessage.success(`${action}成功`);
load();
loadUnreadCount();
} catch (e) {}
}
function statusType(status) {
if (status === '已批准') return 'success';
if (status === '已驳回') return 'danger';
return 'warning';
}
function approverTagType(a, row) {
if (a.status === '已批准') return 'success';
if (a.status === '已驳回') return 'danger';
return a.orderNo === row.currentOrderNo ? 'warning' : 'info';
}
onMounted(() => {
load();
// 多端同步:请假审批状态变化时自动刷新本页
onBusinessChanged((e) => {
if (e.businessType === 'LeaveApproval') load();
});
});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>请假审批</span>
<el-radio-group v-model="statusFilter" @change="load">
<el-radio-button value="待审批">待审批</el-radio-button>
<el-radio-button value="已批准">已批准</el-radio-button>
<el-radio-button value="已驳回">已驳回</el-radio-button>
<el-radio-button value="全部">全部</el-radio-button>
</el-radio-group>
</div>
</template>
<el-table v-loading="loading" :data="leaves" border stripe :row-class-name="rowClass">
<el-table-column prop="employeeName" label="申请人" width="120" />
<el-table-column prop="departmentName" label="部门" width="120" />
<el-table-column prop="type" label="类型" width="90" />
<el-table-column label="开始时间" width="160">
<template #default="{ row }">{{ row.startTime?.slice(0, 16) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="160">
<template #default="{ row }">{{ row.endTime?.slice(0, 16) }}</template>
</el-table-column>
<el-table-column prop="reason" label="事由" min-width="180" />
<el-table-column label="审批流程" min-width="240">
<template #default="{ row }">
<div v-if="row.approvers && row.approvers.length" class="approver-list">
<el-tag v-for="a in row.approvers" :key="a.id" size="small" :type="approverTagType(a, row)" effect="light">
{{ a.orderNo }}.{{ a.approverName }}
</el-tag>
</div>
<span v-else class="done-text">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<template v-if="row.status === '待审批' && isCurrentApprover(row)">
<el-button size="small" type="success" @click="approve(row, true)">批准</el-button>
<el-button size="small" type="danger" @click="approve(row, false)">驳回</el-button>
</template>
<span v-else-if="row.status === '待审批'" class="done-text">等待他人审批</span>
<span v-else class="done-text">已处理</span>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.done-text {
color: #999;
font-size: 13px;
}
.approver-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
:deep(.row-highlight) {
background: #fdf6ec;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage } from 'element-plus';
const leaves = ref([]);
const employees = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const form = reactive({
type: '事假',
startTime: '',
endTime: '',
reason: '',
approverIds: []
});
const leaveTypes = ['事假', '病假', '年假', '调休', '婚假', '产假', '陪产假'];
async function load() {
loading.value = true;
try {
const res = await request.get('/attendance/leave/my');
leaves.value = res.data;
} finally {
loading.value = false;
}
}
// 可选审批人(所有在职员工)
async function loadEmployees() {
try {
const res = await request.get('/sys/user', { params: { page: 1, pageSize: 1000 } });
employees.value = res.data.items || [];
} catch (e) {}
}
async function submit() {
if (!form.startTime || !form.endTime) {
ElMessage.warning('请选择请假起止时间');
return;
}
if (!form.approverIds.length) {
ElMessage.warning('请选择审批人');
return;
}
try {
await request.post('/attendance/leave', form);
ElMessage.success('申请已提交');
dialogVisible.value = false;
form.reason = '';
form.approverIds = [];
load();
} catch (e) {}
}
function statusType(status) {
if (status === '已批准') return 'success';
if (status === '已驳回') return 'danger';
return 'warning';
}
onMounted(() => {
load();
loadEmployees();
});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>请假申请</span>
<el-button type="primary" @click="dialogVisible = true">申请请假</el-button>
</div>
</template>
<el-table v-loading="loading" :data="leaves" border stripe>
<el-table-column prop="type" label="类型" width="100" />
<el-table-column label="开始时间" width="170">
<template #default="{ row }">{{ row.startTime?.slice(0, 16) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="170">
<template #default="{ row }">{{ row.endTime?.slice(0, 16) }}</template>
</el-table-column>
<el-table-column prop="reason" label="事由" min-width="200" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="approverName" label="审批人" width="120" />
<el-table-column label="审批意见" min-width="150">
<template #default="{ row }">{{ row.approveRemark || '-' }}</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="申请请假" width="560px">
<el-form :model="form" label-width="90px">
<el-form-item label="请假类型">
<el-select v-model="form.type" style="width: 100%">
<el-option v-for="t in leaveTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="开始时间">
<el-date-picker v-model="form.startTime" type="datetime" placeholder="选择开始时间" style="width: 100%" value-format="YYYY-MM-DDTHH:mm:ss" />
</el-form-item>
<el-form-item label="结束时间">
<el-date-picker v-model="form.endTime" type="datetime" placeholder="选择结束时间" style="width: 100%" value-format="YYYY-MM-DDTHH:mm:ss" />
</el-form-item>
<el-form-item label="请假事由">
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请填写请假事由" />
</el-form-item>
<el-form-item label="审批人">
<el-select v-model="form.approverIds" multiple filterable placeholder="搜索并选择审批人" style="width: 100%">
<el-option v-for="e in employees" :key="e.id" :label="`${e.userName}(${e.departmentName || '无部门'})`" :value="e.id" />
</el-select>
<div class="form-tip">任意选人、可多选;选择顺序即审批顺序(序号 1、2、3…)</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">提交申请</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-tip {
font-size: 12px;
color: #999;
line-height: 1.4;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script setup>
import { ref, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage } from 'element-plus';
const records = ref([]);
const total = ref(0);
const summary = ref(null);
const loading = ref(false);
const pageIndex = ref(1);
const pageSize = ref(10);
const month = ref(new Date().toISOString().slice(0, 7));
async function loadRecords() {
loading.value = true;
try {
const res = await request.get('/attendance/my', { params: { pageIndex: pageIndex.value, pageSize: pageSize.value } });
records.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function loadSummary() {
try {
const res = await request.get('/attendance/my/summary', { params: { month: month.value } });
summary.value = res.data;
} catch (e) {}
}
function statusType(status) {
if (status === '正常') return 'success';
if (status === '迟到' || status === '早退') return 'warning';
return 'danger';
}
function handlePageChange(page) {
pageIndex.value = page;
loadRecords();
}
onMounted(() => {
loadRecords();
loadSummary();
});
</script>
<template>
<div>
<!-- 统计卡片 -->
<el-card shadow="never" class="summary-card" v-if="summary">
<div class="summary-title">
<span>考勤统计({{ month }})</span>
<el-date-picker
v-model="month"
type="month"
placeholder="选择月份"
value-format="YYYY-MM"
@change="loadSummary"
/>
</div>
<div class="summary-grid">
<div class="stat-box"><div class="num normal">{{ summary.normalDays }}</div><div class="label">正常出勤</div></div>
<div class="stat-box"><div class="num late">{{ summary.lateDays }}</div><div class="label">迟到</div></div>
<div class="stat-box"><div class="num early">{{ summary.earlyLeaveDays }}</div><div class="label">早退</div></div>
<div class="stat-box"><div class="num absent">{{ summary.absentDays }}</div><div class="label">缺勤</div></div>
<div class="stat-box"><div class="num leave">{{ summary.leaveDays }}</div><div class="label">请假</div></div>
<div class="stat-box"><div class="num">{{ summary.totalDays }}</div><div class="label">总天数</div></div>
</div>
</el-card>
<!-- 记录列表 -->
<el-card shadow="never">
<template #header>
<span>我的考勤记录</span>
</template>
<el-table v-loading="loading" :data="records" border stripe>
<el-table-column prop="workDate" label="日期" width="120">
<template #default="{ row }">{{ row.workDate?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="签到时间" width="120">
<template #default="{ row }">{{ row.signInTime ? row.signInTime.slice(11, 19) : '-' }}</template>
</el-table-column>
<el-table-column label="签到状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.signInStatus)" size="small">{{ row.signInStatus || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="签退时间" width="120">
<template #default="{ row }">{{ row.signOutTime ? row.signOutTime.slice(11, 19) : '-' }}</template>
</el-table-column>
<el-table-column label="签退状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.signOutStatus)" size="small">{{ row.signOutStatus || '-' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
:current-page="pageIndex"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</el-card>
</div>
</template>
<style scoped>
.summary-card {
margin-bottom: 16px;
}
.summary-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
font-weight: 600;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
.stat-box {
text-align: center;
padding: 16px;
background: #f8faf9;
border-radius: 8px;
}
.stat-box .num {
font-size: 28px;
font-weight: 700;
}
.stat-box .num.normal { color: #16a34a; }
.stat-box .num.late { color: #f59e0b; }
.stat-box .num.early { color: #f59e0b; }
.stat-box .num.absent { color: #ef4444; }
.stat-box .num.leave { color: #3b82f6; }
.stat-box .label {
font-size: 13px;
color: #999;
margin-top: 6px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup>
import { ref, onMounted } from 'vue';
import request from '../../../utils/request';
import { useUserStore } from '../../../store/user';
import { ElMessage } from 'element-plus';
const userStore = useUserStore();
const user = userStore.userInfo || {};
const now = ref(new Date());
const today = ref('');
const punchResult = ref(null);
const todayRecord = ref(null);
let timer = null;
function formatDate(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function formatTime(d) {
return d.toTimeString().slice(0, 8);
}
async function loadToday() {
try {
const res = await request.get('/attendance/my', { params: { pageIndex: 1, pageSize: 1 } });
const list = res.data.items;
todayRecord.value = list.find((r) => formatDate(new Date(r.workDate)) === today.value) || null;
} catch (e) {}
}
async function punch() {
try {
const res = await request.post('/attendance/punch');
punchResult.value = res.data;
ElMessage.success(res.data.message);
await loadToday();
} catch (e) {}
}
onMounted(() => {
now.value = new Date();
today.value = formatDate(now.value);
loadToday();
timer = setInterval(() => {
now.value = new Date();
}, 1000);
});
</script>
<template>
<div>
<el-card shadow="never" class="punch-card">
<div class="punch-header">
<div class="time-display">{{ formatTime(now) }}</div>
<div class="date-display">{{ today }} · 星期{{ ['日','一','二','三','四','五','六'][now.getDay()] }}</div>
</div>
<div class="punch-info">
<div class="user-avatar">{{ (user.nickname || user.realName || 'U').charAt(0) }}</div>
<div class="user-text">
<div class="user-name">{{ user.nickname || user.realName }}</div>
<div class="user-dept">{{ user.departmentName || '未分配部门' }}</div>
</div>
</div>
<div class="punch-status">
<div class="status-item">
<span class="label">上班签到</span>
<span class="value" :class="{ done: todayRecord?.signInTime }">
{{ todayRecord?.signInTime ? todayRecord.signInTime.slice(11, 19) : '未签到' }}
</span>
<el-tag v-if="todayRecord" :type="todayRecord.signInStatus === '正常' ? 'success' : 'warning'" size="small">
{{ todayRecord.signInStatus }}
</el-tag>
</div>
<div class="status-item">
<span class="label">下班签退</span>
<span class="value" :class="{ done: todayRecord?.signOutTime }">
{{ todayRecord?.signOutTime ? todayRecord.signOutTime.slice(11, 19) : '未签退' }}
</span>
<el-tag v-if="todayRecord?.signOutTime" :type="todayRecord.signOutStatus === '正常' ? 'success' : 'warning'" size="small">
{{ todayRecord.signOutStatus }}
</el-tag>
</div>
</div>
<el-button
type="primary"
size="large"
class="punch-btn"
:disabled="todayRecord?.signOutTime"
@click="punch"
>
{{ !todayRecord ? '上班签到' : !todayRecord.signOutTime ? '下班签退' : '今日已完成' }}
</el-button>
<div v-if="punchResult" class="punch-result">
{{ punchResult.message }}
</div>
</el-card>
</div>
</template>
<style scoped>
.punch-card {
max-width: 500px;
margin: 0 auto;
text-align: center;
}
.punch-header {
padding: 20px 0;
}
.time-display {
font-size: 48px;
font-weight: 700;
color: #16a34a;
font-family: 'Courier New', monospace;
}
.date-display {
font-size: 14px;
color: #999;
margin-top: 8px;
}
.punch-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 20px 0;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #16a34a;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.user-name {
font-weight: 600;
text-align: left;
}
.user-dept {
font-size: 13px;
color: #999;
text-align: left;
}
.punch-status {
display: flex;
justify-content: space-around;
margin: 20px 0;
padding: 16px;
background: #f8faf9;
border-radius: 8px;
}
.status-item {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
.status-item .label {
font-size: 13px;
color: #999;
}
.status-item .value {
font-size: 18px;
font-weight: 600;
color: #ccc;
}
.status-item .value.done {
color: #16a34a;
}
.punch-btn {
width: 200px;
height: 50px;
font-size: 16px;
border-radius: 25px;
}
.punch-result {
margin-top: 16px;
color: #16a34a;
font-size: 14px;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { buildDeptTree } from '../../../utils/dept';
const records = ref([]);
const total = ref(0);
const loading = ref(false);
const deptTree = ref([]);
const query = reactive({
date: '',
departmentId: null,
keyword: '',
pageIndex: 1,
pageSize: 10
});
async function load() {
loading.value = true;
try {
const res = await request.get('/attendance', { params: query });
records.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function loadDepts() {
const res = await request.get('/sys/department');
deptTree.value = buildDeptTree(res.data);
}
function handleSearch() {
query.pageIndex = 1;
load();
}
function resetSearch() {
Object.assign(query, { date: '', departmentId: null, keyword: '', pageIndex: 1, pageSize: 10 });
load();
}
function handlePageChange(page) {
query.pageIndex = page;
load();
}
function statusType(status) {
if (status === '正常') return 'success';
if (status === '迟到' || status === '早退') return 'warning';
return 'danger';
}
onMounted(() => {
load();
loadDepts();
});
</script>
<template>
<el-card shadow="never">
<template #header>
<span>考勤记录</span>
</template>
<div class="search-bar">
<el-date-picker v-model="query.date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 180px" />
<el-tree-select
v-model="query.departmentId"
:data="deptTree"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
clearable
placeholder="选择部门"
style="width: 180px"
/>
<el-input v-model="query.keyword" placeholder="姓名/账号" clearable style="width: 180px" @keyup.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-table v-loading="loading" :data="records" border stripe>
<el-table-column prop="employeeName" label="姓名" width="120" />
<el-table-column prop="departmentName" label="部门" width="120" />
<el-table-column label="日期" width="120">
<template #default="{ row }">{{ row.workDate?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="签到" width="110">
<template #default="{ row }">{{ row.signInTime ? row.signInTime.slice(11, 19) : '缺卡' }}</template>
</el-table-column>
<el-table-column label="签到状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.signInStatus)" size="small">{{ row.signInStatus || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="签退" width="110">
<template #default="{ row }">{{ row.signOutTime ? row.signOutTime.slice(11, 19) : '缺卡' }}</template>
</el-table-column>
<el-table-column label="签退状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.signOutStatus)" size="small">{{ row.signOutStatus || '-' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</el-card>
</template>
<style scoped>
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const guigeOptions = ref([]);
const query = reactive({ pageIndex: 1, pageSize: 20, keyword: '', standard: null, guige: '' });
const dialogVisible = ref(false);
const dialogTitle = ref('新增指标');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
standard: 0, guige: '', grade: '', gradeScore: 0, piancha: 0, dacha: 0, erdu: 0, sandu: 0, qingjie: 0, jiejing: 0
});
const standardOptions = [
{ label: '厂标', value: 0 },
{ label: '国标', value: 1 },
{ label: '个人', value: 2 }
];
async function load() {
loading.value = true;
try {
const res = await request.get('/shengsidingjizhibiao', { params: query });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function loadGuige() {
const res = await request.get('/shengsiguige/all');
guigeOptions.value = res.data;
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增指标';
Object.assign(form, { standard: 0, guige: guigeOptions.value[0] || '', grade: '', gradeScore: 0, piancha: 0, dacha: 0, erdu: 0, sandu: 0, qingjie: 0, jiejing: 0 });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑指标';
const { id, standardName, ...rest } = row;
Object.assign(form, rest);
dialogVisible.value = true;
}
async function save() {
if (!form.grade) { ElMessage.warning('请输入等级'); return; }
try {
if (isEdit.value) {
await request.put(`/shengsidingjizhibiao/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/shengsidingjizhibiao', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
load();
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确定删除等级「${row.grade}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/shengsidingjizhibiao/${row.id}`);
ElMessage.success('删除成功');
load();
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
onMounted(() => { load(); loadGuige(); });
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>生丝定级指标</span>
<el-button type="primary" @click="openCreate">新增指标</el-button>
</div>
</template>
<div class="search-bar">
<el-select v-model="query.standard" clearable placeholder="标准" style="width: 120px">
<el-option v-for="s in standardOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-select v-model="query.guige" clearable placeholder="规格" style="width: 140px">
<el-option v-for="g in guigeOptions" :key="g" :label="g" :value="g" />
</el-select>
<el-input v-model="query.keyword" placeholder="等级" clearable style="width: 140px" @keyup.enter="query.pageIndex=1;load()" />
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
<el-button @click="Object.assign(query,{keyword:'',standard:null,guige:'',pageIndex:1});load()">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="标准" width="80">
<template #default="{ row }">
<el-tag size="small">{{ row.standardName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="guige" label="规格" width="90" />
<el-table-column prop="grade" label="等级" width="90" />
<el-table-column prop="gradeScore" label="等级分值" width="90" />
<el-table-column prop="piancha" label="偏差" width="80" />
<el-table-column prop="dacha" label="大差" width="80" />
<el-table-column prop="erdu" label="二度变化" width="90" />
<el-table-column prop="sandu" label="三度变化" width="90" />
<el-table-column prop="qingjie" label="清洁" width="80" />
<el-table-column prop="jiejing" label="洁净" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="标准">
<el-radio-group v-model="form.standard">
<el-radio-button v-for="s in standardOptions" :key="s.value" :value="s.value">{{ s.label }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="规格">
<el-select v-model="form.guige" style="width: 100%">
<el-option v-for="g in guigeOptions" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item>
<el-form-item label="等级"><el-input v-model="form.grade" /></el-form-item>
<el-form-item label="等级分值"><el-input-number v-model="form.gradeScore" :precision="1" /></el-form-item>
<el-form-item label="偏差"><el-input-number v-model="form.piancha" :precision="2" /></el-form-item>
<el-form-item label="大差"><el-input-number v-model="form.dacha" :precision="2" /></el-form-item>
<el-form-item label="二度变化"><el-input-number v-model="form.erdu" :precision="1" /></el-form-item>
<el-form-item label="三度变化"><el-input-number v-model="form.sandu" :precision="1" /></el-form-item>
<el-form-item label="清洁"><el-input-number v-model="form.qingjie" :precision="1" /></el-form-item>
<el-form-item label="洁净"><el-input-number v-model="form.jiejing" :precision="1" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const query = reactive({ pageIndex: 1, pageSize: 20, keyword: '' });
const dialogVisible = ref(false);
const dialogTitle = ref('新增规格');
const isEdit = ref(false);
const editId = ref(null);
const emptyForm = () => ({
guige: '', centerrangeLolimit: 0, centerrangeUplimit: 0, centerXiandu: 0,
validLolimit: 0, validUplimit: 0, xiaoyeLolimit: 0, xiaoyeUplimit: 0,
dayeLolimit: 0, dayeUplimit: 0, teyeLolimit: 0, teyeUplimit: 0,
yexianNkLolimitLeft: 0, yexianNkLolimitRight: 0, yexianNkUplimitLeft: 0, yexianNkUplimitRight: 0,
laodongJingsaiYexianLolimit: 0, laodongJingsaiYexianUplimit: 0, sort: 0, memo: ''
});
const form = reactive(emptyForm());
async function load() {
loading.value = true;
try {
const res = await request.get('/shengsiguige', { params: query });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增规格';
Object.assign(form, emptyForm());
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑规格';
const { id, ...rest } = row;
Object.assign(form, rest);
dialogVisible.value = true;
}
async function save() {
if (!form.guige) { ElMessage.warning('请输入规格'); return; }
try {
if (isEdit.value) {
await request.put(`/shengsiguige/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/shengsiguige', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
load();
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确定删除规格「${row.guige}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/shengsiguige/${row.id}`);
ElMessage.success('删除成功');
load();
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
onMounted(load);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>生丝规格</span>
<el-button type="primary" @click="openCreate">新增规格</el-button>
</div>
</template>
<div class="search-bar">
<el-input v-model="query.keyword" placeholder="规格" clearable style="width: 200px" @keyup.enter="query.pageIndex=1;load()" />
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="guige" label="规格" width="90" fixed />
<el-table-column prop="centerrangeLolimit" label="中心下限" width="90" />
<el-table-column prop="centerrangeUplimit" label="中心上限" width="90" />
<el-table-column prop="centerXiandu" label="中心纤度" width="90" />
<el-table-column prop="validLolimit" label="有效下限" width="90" />
<el-table-column prop="validUplimit" label="有效上限" width="90" />
<el-table-column prop="xiaoyeLolimit" label="小野下限" width="90" />
<el-table-column prop="xiaoyeUplimit" label="小野上限" width="90" />
<el-table-column prop="dayeLolimit" label="大野下限" width="90" />
<el-table-column prop="dayeUplimit" label="大野上限" width="90" />
<el-table-column prop="teyeLolimit" label="特野下限" width="90" />
<el-table-column prop="teyeUplimit" label="特野上限" width="90" />
<el-table-column prop="laodongJingsaiYexianLolimit" label="野纤下限" width="90" />
<el-table-column prop="laodongJingsaiYexianUplimit" label="野纤上限" width="90" />
<el-table-column prop="sort" label="顺序号" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px">
<el-form :model="form" label-width="130px">
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.guige" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="顺序号"><el-input-number v-model="form.sort" :min="0" /></el-form-item></el-col>
</el-row>
<el-divider content-position="left">中心范围</el-divider>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="中心下限"><el-input-number v-model="form.centerrangeLolimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="中心上限"><el-input-number v-model="form.centerrangeUplimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="中心纤度"><el-input-number v-model="form.centerXiandu" :precision="1" /></el-form-item></el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="有效下限"><el-input-number v-model="form.validLolimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="有效上限"><el-input-number v-model="form.validUplimit" :precision="1" /></el-form-item></el-col>
</el-row>
<el-divider content-position="left">野纤指标</el-divider>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="小野下限"><el-input-number v-model="form.xiaoyeLolimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="小野上限"><el-input-number v-model="form.xiaoyeUplimit" :precision="1" /></el-form-item></el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="大野下限"><el-input-number v-model="form.dayeLolimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="大野上限"><el-input-number v-model="form.dayeUplimit" :precision="1" /></el-form-item></el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="特野下限"><el-input-number v-model="form.teyeLolimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="特野上限"><el-input-number v-model="form.teyeUplimit" :precision="1" /></el-form-item></el-col>
</el-row>
<el-divider content-position="left">内控指标</el-divider>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="内控左下"><el-input-number v-model="form.yexianNkLolimitLeft" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="内控左上"><el-input-number v-model="form.yexianNkLolimitRight" :precision="1" /></el-form-item></el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="内控右下"><el-input-number v-model="form.yexianNkUplimitLeft" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="内控右上"><el-input-number v-model="form.yexianNkUplimitRight" :precision="1" /></el-form-item></el-col>
</el-row>
<el-divider content-position="left">劳动竞赛</el-divider>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="野纤下限"><el-input-number v-model="form.laodongJingsaiYexianLolimit" :precision="1" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="野纤上限"><el-input-number v-model="form.laodongJingsaiYexianUplimit" :precision="1" /></el-form-item></el-col>
</el-row>
<el-form-item label="备注"><el-input v-model="form.memo" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; }
.pagination { margin-top: 16px; justify-content: flex-end; }
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { buildDeptTree } from '../../../utils/dept';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const deptTree = ref([]);
const query = reactive({ pageIndex: 1, pageSize: 20, keyword: '' });
const dialogVisible = ref(false);
const dialogTitle = ref('新增机组车台');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({ departmentId: null, jizu: 0, chetaiStart: 0, chetaiEnd: 0 });
async function load() {
loading.value = true;
try {
const res = await request.get('/jizuchetai', { params: query });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function loadDepts() {
const res = await request.get('/sys/department');
deptTree.value = buildDeptTree(res.data);
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增机组车台';
Object.assign(form, { departmentId: null, jizu: 0, chetaiStart: 0, chetaiEnd: 0 });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑机组车台';
Object.assign(form, { departmentId: row.departmentId, jizu: row.jizu, chetaiStart: row.chetaiStart, chetaiEnd: row.chetaiEnd });
dialogVisible.value = true;
}
async function save() {
if (!form.departmentId) { ElMessage.warning('请选择车间'); return; }
try {
if (isEdit.value) {
await request.put(`/jizuchetai/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/jizuchetai', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
load();
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确定删除机组「${row.jizu}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/jizuchetai/${row.id}`);
ElMessage.success('删除成功');
load();
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
onMounted(() => { load(); loadDepts(); });
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>机组车台</span>
<el-button type="primary" @click="openCreate">新增机组车台</el-button>
</div>
</template>
<div class="search-bar">
<el-input v-model="query.keyword" placeholder="车间/机组号" clearable style="width: 200px" @keyup.enter="query.pageIndex=1;load()" />
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="departmentName" label="车间" min-width="200" />
<el-table-column prop="jizu" label="机组号" width="120" />
<el-table-column prop="chetaiStart" label="开始车台号" width="120" />
<el-table-column prop="chetaiEnd" label="结束车台号" width="120" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px">
<el-form :model="form" label-width="100px">
<el-form-item label="车间">
<el-tree-select
v-model="form.departmentId"
:data="deptTree"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
placeholder="选择车间"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="机组号"><el-input-number v-model="form.jizu" :min="0" /></el-form-item>
<el-form-item label="开始车台号"><el-input-number v-model="form.chetaiStart" :min="0" /></el-form-item>
<el-form-item label="结束车台号"><el-input-number v-model="form.chetaiEnd" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; }
.pagination { margin-top: 16px; justify-content: flex-end; }
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const query = reactive({ pageIndex: 1, pageSize: 20, keyword: '', year: null });
const dialogVisible = ref(false);
const dialogTitle = ref('新增庄口');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
belongingYear: new Date().getFullYear(),
bendiWaidi: 1,
jidiWaigou: '基地',
zhuangKouCode: '',
zhuangKouName: '',
productionDisplayName: '',
chandi: '',
sort: 0,
memo: '',
isJiezhuang: '0'
});
async function load() {
loading.value = true;
try {
const res = await request.get('/craftzhuangkou', { params: query });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增庄口';
Object.assign(form, { belongingYear: new Date().getFullYear(), bendiWaidi: 1, jidiWaigou: '基地', zhuangKouCode: '', zhuangKouName: '', productionDisplayName: '', chandi: '', sort: 0, memo: '', isJiezhuang: '0' });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑庄口';
Object.assign(form, {
belongingYear: row.belongingYear, bendiWaidi: row.bendiWaidi, jidiWaigou: row.jidiWaigou,
zhuangKouCode: row.zhuangKouCode, zhuangKouName: row.zhuangKouName,
productionDisplayName: row.productionDisplayName, chandi: row.chandi,
sort: row.sort, memo: row.memo, isJiezhuang: row.isJiezhuang
});
dialogVisible.value = true;
}
async function save() {
if (!form.zhuangKouName) { ElMessage.warning('请输入庄口名称'); return; }
try {
if (isEdit.value) {
await request.put(`/craftzhuangkou/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/craftzhuangkou', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
load();
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确定删除庄口「${row.zhuangKouName}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/craftzhuangkou/${row.id}`);
ElMessage.success('删除成功');
load();
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
onMounted(load);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>工艺庄口</span>
<el-button type="primary" @click="openCreate">新增庄口</el-button>
</div>
</template>
<div class="search-bar">
<el-input v-model="query.keyword" placeholder="庄口名称/编号" clearable style="width: 220px" @keyup.enter="query.pageIndex=1;load()" />
<el-input-number v-model="query.year" :min="2000" :max="2100" placeholder="年度" style="width: 130px" />
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
<el-button @click="Object.assign(query,{keyword:'',year:null,pageIndex:1});load()">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="belongingYear" label="年度" width="80" />
<el-table-column prop="bendiWaidiName" label="本地外地" width="90" />
<el-table-column prop="jidiWaigou" label="基地外购" width="90" />
<el-table-column prop="zhuangKouCode" label="庄口编号" width="200" />
<el-table-column prop="zhuangKouName" label="庄口名称" min-width="240" />
<el-table-column prop="productionDisplayName" label="生产显示名称" min-width="240" />
<el-table-column prop="chandi" label="产地" width="100" />
<el-table-column prop="sort" label="排序" width="70" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="年度">
<el-input-number v-model="form.belongingYear" :min="2000" :max="2100" />
</el-form-item>
<el-form-item label="本地外地">
<el-radio-group v-model="form.bendiWaidi">
<el-radio :value="1">本地</el-radio>
<el-radio :value="2">外地</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="基地外购">
<el-select v-model="form.jidiWaigou" style="width: 100%">
<el-option label="基地" value="基地" />
<el-option label="外购" value="外购" />
</el-select>
</el-form-item>
<el-form-item label="庄口编号"><el-input v-model="form.zhuangKouCode" /></el-form-item>
<el-form-item label="庄口名称"><el-input v-model="form.zhuangKouName" /></el-form-item>
<el-form-item label="生产显示名称"><el-input v-model="form.productionDisplayName" /></el-form-item>
<el-form-item label="产地"><el-input v-model="form.chandi" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sort" :min="0" /></el-form-item>
<el-form-item label="是否结庄">
<el-radio-group v-model="form.isJiezhuang">
<el-radio value="0">否</el-radio>
<el-radio value="1">是</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.memo" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup>
import { useRouter } from 'vue-router';
import { useUserStore } from '../../store/user';
const router = useRouter();
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: 'AlarmClock', path: '/attendance/punch', color: '#b8873a' }
];
function go(path) { router.push(path); }
</script>
<template>
<div>
<el-card shadow="never" class="welcome-card">
<div class="welcome">
<div class="welcome-text">
<h2>欢迎回来,{{ user.nickname || user.realName }}!</h2>
<p>智慧缫丝系统 · 蚕丝缫制生产数字化管理平台,覆盖落丝、检验、考勤与消息协同。</p>
</div>
<div class="welcome-info">
<div class="info-item">
<span class="label">所属部门</span>
<span class="value">{{ user.departmentName || '-' }}</span>
</div>
<div class="info-item">
<span class="label">当前角色</span>
<span class="value">{{ (user.roles || []).join('、') || '-' }}</span>
</div>
</div>
</div>
</el-card>
<el-row :gutter="20" class="stat-row">
<el-col v-for="link in quickLinks" :key="link.path" :span="6">
<el-card shadow="hover" class="stat-card" @click="go(link.path)">
<div class="stat">
<el-icon class="stat-icon" :color="link.color"><component :is="link.icon" /></el-icon>
<div>
<div class="stat-value">{{ link.title }}</div>
<div class="stat-label">{{ link.desc }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.welcome-card {
margin-bottom: 20px;
}
.welcome {
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text h2 {
margin-bottom: 8px;
color: #1e3a5f;
}
.welcome-text p {
color: #666;
font-size: 14px;
}
.welcome-info {
display: flex;
gap: 40px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.info-item .label {
font-size: 12px;
color: #999;
}
.info-item .value {
font-size: 16px;
color: #333;
font-weight: 600;
}
.stat-card {
cursor: pointer;
}
.stat {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
font-size: 40px;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: #333;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script setup>
import { ref, reactive } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../../store/user';
import { ElMessage } from 'element-plus';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const loginForm = reactive({ username: '', password: '' });
const loading = ref(false);
const loginRules = {
username: [{ required: true, trigger: 'blur', message: '请输入用户名' }],
password: [{ required: true, trigger: 'blur', message: '请输入密码' }]
};
const formRef = ref();
async function handleLogin() {
await formRef.value.validate();
loading.value = true;
try {
await userStore.login(loginForm);
ElMessage.success('登录成功');
const redirect = route.query.redirect || '/';
router.push(redirect);
} catch (e) {
// 错误已在拦截器处理
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="logo-icon">
<el-icon :size="40"><MagicStick /></el-icon>
</div>
<h1>智慧缫丝系统</h1>
<p>蚕丝缫制 · 智慧工厂管理平台</p>
</div>
<el-form
ref="formRef"
:model="loginForm"
:rules="loginRules"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="用户名">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="密码" show-password>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button type="primary" size="large" :loading="loading" class="login-btn" @click="handleLogin">
登 录
</el-button>
</el-form>
<div class="login-tip">默认账号:admin / 123456</div>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0c3b2e 0%, #1e6b52 55%, #c9a54a 150%);
}
.login-card {
width: 400px;
background: #fff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.logo-icon {
color: #1e6b52;
margin-bottom: 8px;
}
.login-header h1 {
font-size: 26px;
color: #175544;
letter-spacing: 2px;
margin-bottom: 8px;
}
.login-header p {
font-size: 14px;
color: #999;
}
.login-btn {
width: 100%;
}
.login-tip {
text-align: center;
color: #999;
font-size: 12px;
margin-top: 16px;
}
</style>
@@ -0,0 +1,182 @@
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import request from '../../../utils/request';
import { ElMessage } from 'element-plus';
import { initNotification, unreadCount, loadUnreadCount, openMessage, onBusinessChanged } from '../../../utils/notification';
const router = useRouter();
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const query = reactive({ pageIndex: 1, pageSize: 20, readStatus: null, category: null });
const priorityMap = {
0: { label: '普通', type: 'info' },
1: { label: '重要', type: 'warning' },
2: { label: '紧急', type: 'danger' }
};
const categoryMap = {
0: { label: '通知', type: 'primary' },
1: { label: '待办', type: 'warning' },
2: { label: '公告', type: 'danger' }
};
async function load() {
loading.value = true;
try {
const params = { pageIndex: query.pageIndex, pageSize: query.pageSize };
if (query.readStatus !== null && query.readStatus !== '') params.readStatus = query.readStatus;
if (query.category !== null && query.category !== '') params.category = query.category;
const res = await request.get('/notification/messages', { params });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function markRead(row) {
if (row.readStatus === 1) return;
try {
await request.put(`/notification/messages/${row.id}/read`);
row.readStatus = 1;
loadUnreadCount();
} catch (e) {}
}
// 点击消息:标记已读 + 跳转业务页(有 routePath 时)
async function handleOpen(row) {
const jumped = await openMessage(row, router);
if (!jumped) {
ElMessage.info('该消息无关联业务页面');
}
}
async function markAllRead() {
try {
await request.put('/notification/messages/read-all');
ElMessage.success('已全部标记为已读');
load();
loadUnreadCount();
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
function resetPage() { query.pageIndex = 1; load(); }
function rowClass({ row }) {
return row.readStatus === 0 ? 'row-unread' : '';
}
function onReceive() {
load();
loadUnreadCount();
}
onMounted(() => {
load();
loadUnreadCount();
initNotification(onReceive);
// 业务状态变化(如请假审批完成)时,刷新消息列表与未读数
onBusinessChanged((e) => {
if (e.businessType === 'LeaveApproval') {
load();
loadUnreadCount();
}
});
});
onBeforeUnmount(() => {});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>
我的消息
<el-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" class="unread-badge" />
</span>
<el-button type="primary" plain @click="markAllRead">全部已读</el-button>
</div>
</template>
<div class="search-bar">
<el-radio-group v-model="query.category" @change="resetPage">
<el-radio-button :value="null">全部</el-radio-button>
<el-radio-button :value="0">通知</el-radio-button>
<el-radio-button :value="1">待办</el-radio-button>
<el-radio-button :value="2">公告</el-radio-button>
</el-radio-group>
<el-radio-group v-model="query.readStatus" class="read-filter" @change="resetPage">
<el-radio-button :value="null">全部</el-radio-button>
<el-radio-button :value="0">未读</el-radio-button>
<el-radio-button :value="1">已读</el-radio-button>
</el-radio-group>
</div>
<el-table v-loading="loading" :data="list" border :row-class-name="rowClass">
<el-table-column label="分类" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="(categoryMap[row.category] || categoryMap[0]).type" effect="light">
{{ (categoryMap[row.category] || categoryMap[0]).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.typeName || row.messageTypeCode }}</el-tag>
</template>
</el-table-column>
<el-table-column label="优先级" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="(priorityMap[row.priority] || priorityMap[0]).type" effect="light">
{{ (priorityMap[row.priority] || priorityMap[0]).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="title-link" :class="{ unread: row.readStatus === 0 }" @click="handleOpen(row)">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ row.createdTime?.replace('T', ' ').slice(0, 19) }}</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.readStatus === 1 ? 'info' : 'success'" effect="light">
{{ row.readStatus === 1 ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="row.routePath" size="small" type="primary" @click="handleOpen(row)">
{{ row.category === 1 ? '去处理' : '查看' }}
</el-button>
<el-button v-else-if="row.readStatus === 0" size="small" type="primary" link @click="markRead(row)">标记已读</el-button>
<span v-else class="done-text">-</span>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.unread-badge { margin-left: 6px; }
.unread { font-weight: 600; }
.title-link { cursor: pointer; color: #409eff; }
.title-link:hover { text-decoration: underline; }
.done-text { color: #c0c4cc; }
::deep(.row-unread) { background: #f0f9ff; }
</style>
@@ -0,0 +1,93 @@
<script setup>
import { ref, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage } from 'element-plus';
import { Bell, Lock } from '@element-plus/icons-vue';
const list = ref([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
const res = await request.get('/notification/subscribe');
list.value = res.data;
} finally {
loading.value = false;
}
}
async function toggle(item) {
if (!item.canSubscribe) { ElMessage.warning('您没有订阅该消息类型的权限'); return; }
try {
await request.post('/notification/subscribe', {
notificationTypeId: item.id,
subscribe: !item.subscribed
});
ElMessage.success(item.subscribed ? '已取消订阅' : '订阅成功');
item.subscribed = !item.subscribed;
} catch (e) {}
}
onMounted(load);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header"><span>消息订阅</span></div>
</template>
<el-alert
title="订阅后,当业务系统触发对应消息时,您将实时收到推送。有权限限制的类型需要对应权限才能订阅。"
type="info" :closable="false" show-icon class="tip"
/>
<div v-loading="loading" class="type-list">
<el-empty v-if="!loading && list.length === 0" description="暂无可订阅的消息类型" />
<el-card
v-for="item in list" :key="item.id"
class="type-card" shadow="hover"
>
<div class="type-row">
<div class="type-info">
<div class="type-name">
<el-icon class="bell"><Bell /></el-icon>
{{ item.name }}
<el-tag v-if="item.requiredPermission" size="small" type="warning" effect="plain" class="perm-tag">
<el-icon><Lock /></el-icon> 需权限
</el-tag>
</div>
<div class="type-desc">{{ item.description || '暂无描述' }}</div>
<div v-if="item.requiredPermission" class="type-perm">权限码:{{ item.requiredPermission }}</div>
</div>
<div class="type-action">
<el-button
v-if="item.canSubscribe"
:type="item.subscribed ? 'default' : 'primary'"
@click="toggle(item)"
>
{{ item.subscribed ? '取消订阅' : '订阅' }}
</el-button>
<el-tooltip v-else content="您没有该类型的订阅权限" placement="top">
<el-button disabled>无权限</el-button>
</el-tooltip>
</div>
</div>
</el-card>
</div>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.tip { margin-bottom: 16px; }
.type-list { display: flex; flex-direction: column; gap: 12px; }
.type-row { display: flex; justify-content: space-between; align-items: center; }
.type-name { display: flex; align-items: center; gap: 6px; font-size: 16px; font-weight: 600; }
.bell { color: #409eff; }
.perm-tag { margin-left: 4px; }
.type-desc { margin-top: 6px; color: #606266; font-size: 13px; }
.type-perm { margin-top: 4px; color: #c0c4cc; font-size: 12px; }
.type-action { margin-left: 16px; }
</style>
+183
View File
@@ -0,0 +1,183 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('新增消息类型');
const isEdit = ref(false);
const editId = ref(null);
const categoryMap = [
{ value: 0, label: '通知' },
{ value: 1, label: '待办' },
{ value: 2, label: '公告' }
];
const form = reactive({
code: '', name: '', description: '', requiredPermission: '', isActive: true, sort: 0,
category: 0, icon: '', routeTemplate: ''
});
async function load() {
loading.value = true;
try {
const res = await request.get('/notification/type');
list.value = res.data;
} finally {
loading.value = false;
}
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增消息类型';
Object.assign(form, { code: '', name: '', description: '', requiredPermission: '', isActive: true, sort: 0, category: 0, icon: '', routeTemplate: '' });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑消息类型';
Object.assign(form, {
code: row.code, name: row.name, description: row.description,
requiredPermission: row.requiredPermission, isActive: row.isActive, sort: row.sort,
category: row.category ?? 0, icon: row.icon || '', routeTemplate: row.routeTemplate || ''
});
dialogVisible.value = true;
}
function categoryLabel(category) {
return (categoryMap.find(c => c.value === category) || categoryMap[0]).label;
}
async function save() {
if (!form.code || !form.name) { ElMessage.warning('编码和名称不能为空'); return; }
try {
if (isEdit.value) {
await request.put(`/notification/type/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/notification/type', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
load();
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm(`确定删除消息类型「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/notification/type/${row.id}`);
ElMessage.success('删除成功');
load();
} catch (e) {}
}
onMounted(load);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>消息类型管理</span>
<el-button type="primary" @click="openCreate">新增消息类型</el-button>
</div>
</template>
<el-alert
title="消息类型定义系统中有哪些消息,以及订阅该类型需要什么权限。权限码为空表示所有用户可订阅。"
type="info" :closable="false" show-icon class="tip"
/>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="code" label="编码" width="160" />
<el-table-column prop="name" label="名称" width="130" />
<el-table-column label="分类" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.category === 1 ? 'warning' : (row.category === 2 ? 'danger' : 'primary')" effect="light">
{{ categoryLabel(row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="跳转路由" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.routeTemplate" class="route-text">{{ row.routeTemplate }}</span>
<span v-else class="done-text">-</span>
</template>
</el-table-column>
<el-table-column label="所需权限" min-width="180">
<template #default="{ row }">
<el-tag v-if="row.requiredPermission" size="small" type="warning" effect="plain">{{ row.requiredPermission }}</el-tag>
<el-tag v-else size="small" type="success" effect="plain">所有人可订阅</el-tag>
</template>
</el-table-column>
<el-table-column label="开放订阅" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isActive ? 'success' : 'info'">{{ row.isActive ? '开放' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="编码">
<el-input v-model="form.code" placeholder="如 TeyeFound" :disabled="isEdit" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="如 发现特野" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.category" style="width: 100%">
<el-option v-for="c in categoryMap" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
<el-form-item label="图标">
<el-input v-model="form.icon" placeholder="Element Plus 图标名,如 Bell、Checked" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="展示在订阅页面的说明" />
</el-form-item>
<el-form-item label="跳转路由">
<el-input v-model="form.routeTemplate" placeholder="如 /attendance/approve?leaveId={businessId},留空则无跳转" />
<div class="form-tip">支持 {businessId} 占位符,发送消息时自动替换为业务单据 ID</div>
</el-form-item>
<el-form-item label="所需权限">
<el-input v-model="form.requiredPermission" placeholder="权限码,留空表示所有用户可订阅" />
</el-form-item>
<el-form-item label="开放订阅">
<el-switch v-model="form.isActive" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.tip { margin-bottom: 16px; }
.form-tip { font-size: 12px; color: #999; line-height: 1.4; }
.route-text { font-size: 12px; color: #409eff; }
.done-text { color: #c0c4cc; }
</style>
+300
View File
@@ -0,0 +1,300 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import request from '../../../utils/request';
import { ElMessage } from 'element-plus';
// ==================== 列表 ====================
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const deptOptions = ref([]);
const query = reactive({ pageIndex: 1, pageSize: 20, date: '', deptId: null });
async function loadDepts() {
try {
const res = await request.get('/sys/department');
deptOptions.value = (res.data || []).filter((d) => d.name.includes('前缫'));
} catch (e) {}
}
async function load() {
loading.value = true;
try {
const params = { pageIndex: query.pageIndex, pageSize: query.pageSize };
if (query.date) params.date = query.date;
if (query.deptId) params.deptId = query.deptId;
const res = await request.get('/hbjy', { params });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
// ==================== 编辑对话框 ====================
const dialogVisible = ref(false);
const editLoading = ref(false);
const saving = ref(false);
const editData = ref(null);
// 一度/二度/三度
const yiduColumns = [
{ key: 'ydYidu', label: '一度' },
{ key: 'ydErdu', label: '二度' },
{ key: 'ydSandu', label: '三度' },
];
// 洁净等级
const jjColumns = [
{ key: 'jj100', label: '100' },
{ key: 'jj95', label: '95' },
{ key: 'jj90', label: '90' },
{ key: 'jj85', label: '85' },
{ key: 'jj80', label: '80' },
{ key: 'jj75', label: '75' },
{ key: 'jj70', label: '70' },
{ key: 'jj65', label: '65' },
{ key: 'jj60', label: '60' },
];
// 清洁疵点
const qjColumns = [
{ key: 'qjZycdTdc', label: '特大糙' },
{ key: 'qjCycdFs', label: '废丝' },
{ key: 'qjCycdDc', label: '大糙' },
{ key: 'qjCycdNfc', label: '粘付糙' },
{ key: 'qjCycdDcj', label: '大长结' },
{ key: 'qjCycdZlx', label: '重螺旋' },
{ key: 'qjPtcdXc', label: '小糙' },
{ key: 'qjPtcdCj', label: '长结' },
{ key: 'qjPtcdLx', label: '螺旋' },
{ key: 'qjPtcdHjls', label: '环结及裂丝' },
];
function num(v) {
if (typeof v === 'number') return isFinite(v) ? v : 0;
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return isFinite(n) ? n : 0;
}
function round2(v) { return Math.round(v * 100) / 100; }
// 片数 = 洁净等级各列之和
function calcPianshu(row) {
return jjColumns.reduce((s, c) => s + num(row[c.key]), 0);
}
function calcRow(row) {
row.pianshu = calcPianshu(row);
}
function onInput(row, key, v) {
row[key] = num(v);
calcRow(row);
}
function selectAll(e) {
const el = e?.target;
if (el && typeof el.select === 'function') setTimeout(() => el.select(), 0);
}
// 汇总
const summary = computed(() => {
const rows = editData.value?.records || [];
const pianshu = rows.reduce((s, r) => s + (r.pianshu || 0), 0);
// 清洁扣分
let totalDeduction = 0;
rows.forEach((r) => {
totalDeduction += num(r.qjZycdTdc) * 1.0;
totalDeduction += (num(r.qjCycdFs) + num(r.qjCycdDc) + num(r.qjCycdNfc) + num(r.qjCycdDcj) + num(r.qjCycdZlx)) * 0.4;
totalDeduction += (num(r.qjPtcdXc) + num(r.qjPtcdCj) + num(r.qjPtcdLx) + num(r.qjPtcdHjls)) * 0.1;
});
const qingjie = pianshu === 0 ? 0 : round2((pianshu - totalDeduction) * 100 / pianshu);
// 洁净扣分
let jjDeduction = 0;
jjDeduction += 1 * rows.reduce((s, r) => s + num(r.jj95), 0);
jjDeduction += 2 * rows.reduce((s, r) => s + num(r.jj90), 0);
jjDeduction += 3 * rows.reduce((s, r) => s + num(r.jj85), 0);
jjDeduction += 4 * rows.reduce((s, r) => s + num(r.jj80), 0);
jjDeduction += 5 * rows.reduce((s, r) => s + num(r.jj75), 0);
jjDeduction += 6 * rows.reduce((s, r) => s + num(r.jj70), 0);
jjDeduction += 7 * rows.reduce((s, r) => s + num(r.jj65), 0);
jjDeduction += 8 * rows.reduce((s, r) => s + num(r.jj60), 0);
const jiejing = pianshu === 0 ? 0 : round2((pianshu * 100 - jjDeduction * 5) / pianshu);
return { pianshu, qingjie, jiejing };
});
async function openEdit(row) {
editLoading.value = true;
dialogVisible.value = true;
try {
const res = await request.get(`/hbjy/${row.lsjlId}`);
editData.value = res.data;
editData.value.records.forEach(calcRow);
} catch (e) {
dialogVisible.value = false;
} finally {
editLoading.value = false;
}
}
async function save() {
if (!editData.value) return;
if (summary.value.pianshu === 0) { ElMessage.warning('请至少录入一个洁净等级片数'); return; }
saving.value = true;
try {
await request.post('/hbjy', {
lsjlId: editData.value.lsjlId,
dengji: editData.value.dengji || '',
records: editData.value.records.map((r) => ({
chetaihao: r.chetaihao,
ydYidu: num(r.ydYidu), ydErdu: num(r.ydErdu), ydSandu: num(r.ydSandu),
jj100: num(r.jj100), jj95: num(r.jj95), jj90: num(r.jj90), jj85: num(r.jj85),
jj80: num(r.jj80), jj75: num(r.jj75), jj70: num(r.jj70), jj65: num(r.jj65), jj60: num(r.jj60),
qjZycdTdc: num(r.qjZycdTdc),
qjCycdFs: num(r.qjCycdFs), qjCycdDc: num(r.qjCycdDc), qjCycdNfc: num(r.qjCycdNfc),
qjCycdDcj: num(r.qjCycdDcj), qjCycdZlx: num(r.qjCycdZlx),
qjPtcdXc: num(r.qjPtcdXc), qjPtcdCj: num(r.qjPtcdCj), qjPtcdLx: num(r.qjPtcdLx), qjPtcdHjls: num(r.qjPtcdHjls)
}))
});
ElMessage.success('保存成功');
dialogVisible.value = false;
load();
} catch (e) {} finally {
saving.value = false;
}
}
onMounted(() => { load(); loadDepts(); });
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header"><span>黑板检验</span></div>
</template>
<div class="search-bar">
<el-date-picker v-model="query.date" type="date" placeholder="日期" value-format="YYYY-MM-DD" style="width: 160px" />
<el-select v-model="query.deptId" clearable placeholder="车间" style="width: 180px">
<el-option v-for="d in deptOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
<el-button @click="Object.assign(query,{date:'',deptId:null,pageIndex:1});load()">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column type="index" label="序号" width="55" />
<el-table-column label="日期" width="100">
<template #default="{ row }">{{ row.workDate?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column prop="deptName" label="车间" width="110" />
<el-table-column prop="jizu" label="机组" width="60" />
<el-table-column prop="huishu" label="回数" width="60" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button size="small" :type="row.isChecked ? 'warning' : 'primary'" @click="openEdit(row)">
{{ row.isChecked ? '重新录入' : '录入' }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="guige" label="规格" width="75" />
<el-table-column prop="zhuangKouName" label="庄口" min-width="160" show-overflow-tooltip />
<el-table-column prop="yanse" label="颜色" width="75" />
<el-table-column prop="isbancheName" label="半车" width="65" />
<el-table-column prop="luosiTime" label="落丝时间" width="90" />
<el-table-column prop="chetaishu" label="车台数" width="70" />
<el-table-column prop="yingluPianshu" label="应录片数" width="80" />
<el-table-column label="已录数" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.yingluPianshu > row.pianshu ? 'warning' : 'success'" size="small" effect="light">{{ row.pianshu }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="qingjie" label="清洁" width="70" />
<el-table-column prop="jiejing" label="洁净" width="70" />
<el-table-column prop="dengji" label="等级" width="65" />
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" title="黑板检验" width="95%" top="2vh" destroy-on-close>
<div v-loading="editLoading">
<template v-if="editData">
<!-- 落丝信息 -->
<el-descriptions :column="7" border size="small" class="info-block">
<el-descriptions-item label="车间">{{ editData.deptName }}</el-descriptions-item>
<el-descriptions-item label="机组">{{ editData.jizu }}</el-descriptions-item>
<el-descriptions-item label="回数">{{ editData.huishu }}</el-descriptions-item>
<el-descriptions-item label="庄口">{{ editData.zhuangKouName }}</el-descriptions-item>
<el-descriptions-item label="颜色">{{ editData.yanse }}</el-descriptions-item>
<el-descriptions-item label="半车">{{ editData.isbancheName }}</el-descriptions-item>
<el-descriptions-item label="规格">{{ editData.guige }}</el-descriptions-item>
</el-descriptions>
<!-- 车台明细 -->
<el-table :data="editData.records" border size="small" max-height="420" class="hbjy-table">
<el-table-column prop="chetaihao" label="车台" width="70" align="center" fixed />
<el-table-column v-for="c in yiduColumns" :key="c.key" :label="c.label" width="70" align="center">
<template #default="{ row }">
<el-input-number v-model="row[c.key]" :min="0" :controls="false" class="hb-input" @focus="selectAll" @input="(v) => onInput(row, c.key, v)" />
</template>
</el-table-column>
<el-table-column v-for="c in jjColumns" :key="c.key" :label="c.label" width="70" align="center">
<template #default="{ row }">
<el-input-number v-model="row[c.key]" :min="0" :controls="false" class="hb-input" @focus="selectAll" @input="(v) => onInput(row, c.key, v)" />
</template>
</el-table-column>
<el-table-column v-for="c in qjColumns" :key="c.key" :label="c.label" width="80" align="center">
<template #default="{ row }">
<el-input-number v-model="row[c.key]" :min="0" :controls="false" class="hb-input" @focus="selectAll" @input="(v) => onInput(row, c.key, v)" />
</template>
</el-table-column>
<el-table-column prop="pianshu" label="片数" width="70" align="center" fixed="right" />
</el-table>
<!-- 汇总 -->
<div class="summary-bar">
<div class="stat"><span class="lbl">已录片数</span><b>{{ summary.pianshu }}</b></div>
<div class="stat"><span class="lbl">应录片数</span><b class="c-gray">{{ editData.yingluPianshu }}</b></div>
<div class="stat"><span class="lbl">清洁分</span><b class="c-blue">{{ summary.qingjie }}</b></div>
<div class="stat"><span class="lbl">洁净分</span><b class="c-green">{{ summary.jiejing }}</b></div>
<div class="stat level">
<span class="lbl">等级</span>
<el-input v-model="editData.dengji" placeholder="等级" style="width: 90px" />
</div>
</div>
</template>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.info-block { margin-bottom: 12px; }
.hb-input :deep(.el-input__wrapper) { padding: 0 4px; }
.hb-input :deep(.el-input__inner) { text-align: center; }
.summary-bar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 16px; padding: 14px 16px; background: #f5f7fa; border-radius: 8px; }
.stat { display: flex; flex-direction: column; align-items: center; min-width: 70px; padding: 6px 14px; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.stat .lbl { font-size: 12px; color: #909399; }
.stat b { font-size: 18px; color: #303133; }
.stat .c-blue { color: #409eff; }
.stat .c-green { color: #67c23a; }
.stat .c-gray { color: #909399; }
.stat.level { flex-direction: row; gap: 6px; align-items: center; }
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const deptOptions = ref([]); // 前缫车间
const zhuangkouOptions = ref([]); // 工艺庄口
const guigeOptions = ref([]); // 规格
const query = reactive({ pageIndex: 1, pageSize: 20, date: '', deptId: null, keyword: '' });
// 颜色字典(写死)
const yanseOptions = ['蓝色', '绿色', '乌红', '深红', '桔红', '蓝方', '黄方', '绿方', '紫方', '红方', '金方', '红圆', '桔圆', '绿圆', '黄圆', '粉圆', '蓝圆', '嫩绿'];
const jizuOptions = [1, 2, 3, 4, 5, 6];
const dialogVisible = ref(false);
const dialogTitle = ref('新增落丝记录');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
workDate: new Date(), deptId: null, gyzkId: null, jizu: 1,
luosiTime: '', yanse: '', huishu: 0, chetaiStart: 0, chetaiEnd: 0,
chetaishu: 0, isbanche: false, hours: 0, guige: '', chesu: 0
});
async function load() {
loading.value = true;
try {
const params = { pageIndex: query.pageIndex, pageSize: query.pageSize, keyword: query.keyword };
if (query.date) params.date = query.date;
if (query.deptId) params.deptId = query.deptId;
const res = await request.get('/lsjl', { params });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
// 加载前缫车间(名称含"前缫"的部门)
async function loadDepts() {
const res = await request.get('/sys/department');
deptOptions.value = (res.data || []).filter((d) => d.name.includes('前缫'));
}
async function loadZhuangkou() {
const res = await request.get('/craftzhuangkou', { params: { pageIndex: 1, pageSize: 1000 } });
zhuangkouOptions.value = res.data.items;
}
async function loadGuige() {
const res = await request.get('/shengsiguige/all');
guigeOptions.value = res.data;
}
// 车间+机组 → 自动带出车台号
async function loadChetai() {
if (!form.deptId || !form.jizu) return;
try {
const res = await request.get('/lsjl/chetai', { params: { deptId: form.deptId, jizu: form.jizu } });
if (res.data) {
form.chetaiStart = res.data.chetaiStart;
form.chetaiEnd = res.data.chetaiEnd;
calcChetaishu();
}
} catch (e) {}
}
function calcChetaishu() {
if (form.chetaiStart > 0 && form.chetaiEnd > form.chetaiStart) {
form.chetaishu = form.chetaiEnd - form.chetaiStart + 1;
}
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增落丝记录';
Object.assign(form, {
workDate: new Date(), deptId: deptOptions.value[0]?.id || null, gyzkId: null, jizu: 1,
luosiTime: '', yanse: yanseOptions[0] || '', huishu: 0, chetaiStart: 0, chetaiEnd: 0,
chetaishu: 0, isbanche: false, hours: 0, guige: guigeOptions.value[0] || '', chesu: 0
});
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '修改落丝记录';
Object.assign(form, {
workDate: row.workDate, deptId: row.deptId, gyzkId: row.gyzkId, jizu: row.jizu,
luosiTime: row.luosiTime, yanse: row.yanse, huishu: row.huishu,
chetaiStart: row.chetaiStart, chetaiEnd: row.chetaiEnd, chetaishu: row.chetaishu,
isbanche: row.isbanche, hours: row.hours, guige: row.guige, chesu: row.chesu
});
dialogVisible.value = true;
}
async function save() {
if (!form.deptId) { ElMessage.warning('请选择车间'); return; }
if (!form.gyzkId) { ElMessage.warning('请选择庄口'); return; }
try {
if (isEdit.value) {
await request.put(`/lsjl/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/lsjl', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
load();
} catch (e) {}
}
async function del(row) {
await ElMessageBox.confirm('确定删除这条落丝记录吗?', '提示', { type: 'warning' });
try {
await request.delete(`/lsjl/${row.id}`);
ElMessage.success('删除成功');
load();
} catch (e) {}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
onMounted(() => { load(); loadDepts(); loadZhuangkou(); loadGuige(); });
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>落丝记录</span>
<el-button type="primary" @click="openCreate">新增落丝记录</el-button>
</div>
</template>
<div class="search-bar">
<el-date-picker v-model="query.date" type="date" placeholder="日期" value-format="YYYY-MM-DD" style="width: 160px" />
<el-select v-model="query.deptId" clearable placeholder="车间" style="width: 160px">
<el-option v-for="d in deptOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-input v-model="query.keyword" placeholder="庄口/规格" clearable style="width: 180px" @keyup.enter="query.pageIndex=1;load()" />
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
<el-button @click="Object.assign(query,{date:'',deptId:null,keyword:'',pageIndex:1});load()">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="日期" width="100">
<template #default="{ row }">{{ row.workDate?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column prop="deptName" label="车间" width="120" />
<el-table-column prop="jizu" label="机组" width="70" />
<el-table-column prop="guige" label="规格" width="80" />
<el-table-column prop="zhuangKouName" label="庄口" min-width="180" />
<el-table-column prop="huishu" label="回数" width="70" />
<el-table-column prop="yanse" label="颜色" width="80" />
<el-table-column prop="isbancheName" label="半车" width="70" />
<el-table-column prop="luosiTime" label="落丝时间" width="90" />
<el-table-column prop="hours" label="时长" width="70" />
<el-table-column prop="chetaishu" label="车台数" width="80" />
<el-table-column prop="chetaiStart" label="开始车台" width="90" />
<el-table-column prop="chetaiEnd" label="结束车台" width="90" />
<el-table-column prop="chesu" label="车速" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px">
<el-form :model="form" label-width="100px">
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="日期">
<el-date-picker v-model="form.workDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="车间">
<el-select v-model="form.deptId" style="width: 100%" @change="loadChetai">
<el-option v-for="d in deptOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="机组">
<el-select v-model="form.jizu" style="width: 100%" @change="loadChetai">
<el-option v-for="j in jizuOptions" :key="j" :label="j" :value="j" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="庄口">
<el-select v-model="form.gyzkId" filterable style="width: 100%">
<el-option v-for="z in zhuangkouOptions" :key="z.id" :label="z.zhuangKouName" :value="z.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="颜色">
<el-select v-model="form.yanse" style="width: 100%">
<el-option v-for="y in yanseOptions" :key="y" :label="y" :value="y" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="回数">
<el-input-number v-model="form.huishu" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="落丝时间">
<el-time-picker v-model="form.luosiTime" value-format="HH:mm:ss" placeholder="落丝时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="缫丝时长">
<el-input-number v-model="form.hours" :min="0" :precision="1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8">
<el-form-item label="开始车台">
<el-input-number v-model="form.chetaiStart" :min="0" style="width: 100%" @change="calcChetaishu" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束车台">
<el-input-number v-model="form.chetaiEnd" :min="0" style="width: 100%" @change="calcChetaishu" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="车台数">
<el-input-number v-model="form.chetaishu" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="规格">
<el-select v-model="form.guige" style="width: 100%">
<el-option v-for="g in guigeOptions" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="车速">
<el-input-number v-model="form.chesu" :min="0" :precision="1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="半车">
<el-switch v-model="form.isbanche" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
</style>
+374
View File
@@ -0,0 +1,374 @@
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import request from '../../../utils/request';
import { ElMessage } from 'element-plus';
import { CircleCloseFilled, WarningFilled, InfoFilled } from '@element-plus/icons-vue';
// ==================== 列表 ====================
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const deptOptions = ref([]);
const query = reactive({ pageIndex: 1, pageSize: 20, date: '', deptId: null });
async function loadDepts() {
try {
const res = await request.get('/sys/department');
deptOptions.value = (res.data || []).filter((d) => d.name.includes('前缫'));
} catch (e) {}
}
async function load() {
loading.value = true;
try {
const params = { pageIndex: query.pageIndex, pageSize: query.pageSize };
if (query.date) params.date = query.date;
if (query.deptId) params.deptId = query.deptId;
const res = await request.get('/xdjy', { params });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
function handlePageChange(p) { query.pageIndex = p; load(); }
// ==================== 编辑对话框 ====================
const dialogVisible = ref(false);
const editLoading = ref(false);
const saving = ref(false);
const editData = ref(null);
const gg = computed(() => editData.value?.guigeConfig || null);
// 纤度列配置
const xdColumns = [
{ key: 'xd1', label: '纤度1' },
{ key: 'xd2', label: '纤度2' },
{ key: 'xd3', label: '纤度3' },
{ key: 'xd4', label: '纤度4' },
];
function num(v) {
if (typeof v === 'number') return isFinite(v) ? v : 0;
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return isFinite(n) ? n : 0;
}
function round2(v) { return Math.round(v * 100) / 100; }
// 野纤等级:teye(特野) / daye(大野) / xiaoye(小野) / null(正常)
function xdLevel(xd) {
const g = gg.value;
const v = num(xd);
if (!g || v <= 0) return null;
if (v <= g.teyeLolimit || v >= g.teyeUplimit) return 'teye';
if (v <= g.dayeLolimit || v >= g.dayeUplimit) return 'daye';
if (v <= g.xiaoyeLolimit || v >= g.xiaoyeUplimit) return 'xiaoye';
return null;
}
// 范围(在中心范围内)
function isInRange(xd) {
const g = gg.value;
const v = num(xd);
return !!g && v > 0 && g.centerrangeLolimit <= v && v <= g.centerrangeUplimit;
}
// 中心(等于中心纤度)
function isCenter(xd) {
const g = gg.value;
const v = num(xd);
return !!g && v > 0 && g.centerXiandu === v;
}
// 计算单行指标
function calcRow(row) {
const xds = [num(row.xd1), num(row.xd2), num(row.xd3), num(row.xd4)];
const valid = xds.filter((v) => v > 0);
row.xdNum = valid.length;
if (valid.length > 0) {
row.xdAvg = round2(xds.reduce((a, b) => a + b, 0) / valid.length);
row.fanweiNum = xds.filter(isInRange).length;
row.xiaoyeNum = xds.filter((v) => xdLevel(v) === 'xiaoye').length;
row.dayeNum = xds.filter((v) => xdLevel(v) === 'daye').length;
row.teyeNum = xds.filter((v) => xdLevel(v) === 'teye').length;
row.centerNum = xds.filter(isCenter).length;
} else {
row.xdAvg = 0; row.fanweiNum = 0; row.xiaoyeNum = 0; row.dayeNum = 0; row.teyeNum = 0; row.centerNum = 0;
}
}
// 汇总指标
const summary = computed(() => {
const items = editData.value?.xdjy || [];
let jiaoshu = 0, sum = 0, fanweiNum = 0, xiaoyeNum = 0, dayeNum = 0, teyeNum = 0, centerNum = 0;
const xds = [];
items.forEach((r) => {
[num(r.xd1), num(r.xd2), num(r.xd3), num(r.xd4)].forEach((xd) => {
if (xd > 0) { jiaoshu++; sum += xd; xds.push(xd); }
});
fanweiNum += r.fanweiNum || 0; xiaoyeNum += r.xiaoyeNum || 0; dayeNum += r.dayeNum || 0;
teyeNum += r.teyeNum || 0; centerNum += r.centerNum || 0;
});
const avg = jiaoshu > 0 ? sum / jiaoshu : 0;
let junfangcha = 0;
if (jiaoshu > 0) {
let diff = 0;
xds.forEach((x) => { const d = x - avg; diff += d * d; });
junfangcha = round2(Math.sqrt(round2(diff / jiaoshu)));
}
let zongcha = 0;
if (jiaoshu > 0) {
xds.sort((a, b) => a - b);
const n = Math.max(1, Math.ceil(xds.length * 0.02));
const minAvg = xds.slice(0, n).reduce((a, b) => a + b, 0) / n;
const maxAvg = xds.slice(xds.length - n).reduce((a, b) => a + b, 0) / n;
zongcha = round2(Math.max(Math.abs(minAvg - round2(avg)), Math.abs(maxAvg - round2(avg))));
}
const fanxianLv = jiaoshu > 0 ? Math.round(fanweiNum * 100 / jiaoshu) : 0;
return { jiaoshu, avgxiandu: round2(avg), junfangcha, zongcha, fanxianLv, fanweiNum, xiaoyeNum, dayeNum, teyeNum, centerNum };
});
// 纤度输入:显式赋值 + 重算,避免事件时序问题
function onXdInput(row, key, v) {
row[key] = num(v);
calcRow(row);
}
// 单击选中整个单元格(聚焦时全选,方便直接覆盖输入)
function selectAll(e) {
const el = e?.target;
if (el && typeof el.select === 'function') {
setTimeout(() => el.select(), 0);
}
}
async function openEdit(row) {
editLoading.value = true;
dialogVisible.value = true;
try {
const res = await request.get(`/xdjy/${row.lsjlId}`);
editData.value = res.data;
editData.value.xdjy.forEach(calcRow);
} catch (e) {
dialogVisible.value = false;
} finally {
editLoading.value = false;
}
}
async function save() {
if (!editData.value) return;
if (summary.value.jiaoshu === 0) { ElMessage.warning('请至少录入一个纤度值'); return; }
saving.value = true;
try {
await request.post('/xdjy', {
lsjlId: editData.value.lsjlId,
dengji: editData.value.dengji || '',
items: editData.value.xdjy.map((i) => ({
chetaihao: i.chetaihao,
xd1: num(i.xd1), xd2: num(i.xd2), xd3: num(i.xd3), xd4: num(i.xd4)
}))
});
ElMessage.success('保存成功');
dialogVisible.value = false;
load();
} catch (e) {} finally {
saving.value = false;
}
}
onMounted(() => { load(); loadDepts(); });
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>纤度检验</span>
</div>
</template>
<div class="search-bar">
<el-date-picker v-model="query.date" type="date" placeholder="日期" value-format="YYYY-MM-DD" style="width: 160px" />
<el-select v-model="query.deptId" clearable placeholder="车间" style="width: 180px">
<el-option v-for="d in deptOptions" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-button type="primary" @click="query.pageIndex=1;load()">搜索</el-button>
<el-button @click="Object.assign(query,{date:'',deptId:null,pageIndex:1});load()">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column type="index" label="序号" width="55" />
<el-table-column label="日期" width="100">
<template #default="{ row }">{{ row.workDate?.slice(0, 10) }}</template>
</el-table-column>
<el-table-column prop="deptName" label="车间" width="110" />
<el-table-column prop="jizu" label="机组" width="60" />
<el-table-column prop="huishu" label="回数" width="60" />
<el-table-column prop="guige" label="规格" width="75" />
<el-table-column prop="zhuangKouName" label="庄口" min-width="160" show-overflow-tooltip />
<el-table-column prop="yanse" label="颜色" width="75" />
<el-table-column prop="isbancheName" label="半车" width="65" />
<el-table-column prop="chetaishu" label="车台数" width="70" />
<el-table-column prop="luosiTime" label="落丝时间" width="90" />
<el-table-column prop="yingchengnum" label="应秤数" width="70" />
<el-table-column label="已秤数" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.yingchengnum > row.jiaoshu ? 'warning' : 'success'" size="small" effect="light">{{ row.jiaoshu }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="avgxiandu" label="平均纤度" width="85" />
<el-table-column prop="junfangcha" label="均方差" width="75" />
<el-table-column prop="zongcha" label="总差" width="65" />
<el-table-column prop="fanxianLv" label="范纤率" width="70" />
<el-table-column prop="centerNum" label="中心" width="60" />
<el-table-column prop="xiaoyeNum" label="小野" width="60" />
<el-table-column prop="dayeNum" label="大野" width="60" />
<el-table-column prop="teyeNum" label="特野" width="60" />
<el-table-column prop="dengji" label="等级" width="65" />
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button size="small" :type="row.isChecked ? 'warning' : 'primary'" @click="openEdit(row)">
{{ row.isChecked ? '重新检验' : '检验' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" :current-page="query.pageIndex" :page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" />
<!-- 检验对话框 -->
<el-dialog v-model="dialogVisible" title="纤度检验" width="94%" top="2vh" destroy-on-close>
<div v-loading="editLoading">
<template v-if="editData">
<!-- 落丝信息 -->
<el-descriptions :column="6" border size="small" class="info-block">
<el-descriptions-item label="车间">{{ editData.deptName }}</el-descriptions-item>
<el-descriptions-item label="机组">{{ editData.jizu }}</el-descriptions-item>
<el-descriptions-item label="回数">{{ editData.huishu }}</el-descriptions-item>
<el-descriptions-item label="庄口">{{ editData.zhuangKouName }}</el-descriptions-item>
<el-descriptions-item label="颜色">{{ editData.yanse }}</el-descriptions-item>
<el-descriptions-item label="半车">{{ editData.isbancheName }}</el-descriptions-item>
</el-descriptions>
<!-- 规格范围 -->
<el-descriptions v-if="gg" :column="6" border size="small" class="info-block">
<el-descriptions-item label="规格">{{ gg.guige }}</el-descriptions-item>
<el-descriptions-item label="中心范围">[{{ gg.centerrangeLolimit }} - {{ gg.centerrangeUplimit }}]</el-descriptions-item>
<el-descriptions-item label="中心纤度">{{ gg.centerXiandu }}</el-descriptions-item>
<el-descriptions-item label="小野范围" class="d-xiaoye">≤{{ gg.xiaoyeLolimit }} / ≥{{ gg.xiaoyeUplimit }}</el-descriptions-item>
<el-descriptions-item label="大野范围" class="d-daye">≤{{ gg.dayeLolimit }} / ≥{{ gg.dayeUplimit }}</el-descriptions-item>
<el-descriptions-item label="特野范围" class="d-teye">≤{{ gg.teyeLolimit }} / ≥{{ gg.teyeUplimit }}</el-descriptions-item>
</el-descriptions>
<!-- 车台明细 -->
<el-table :data="editData.xdjy" border size="small" max-height="440">
<el-table-column prop="chetaihao" label="车台号" width="70" align="center" fixed />
<el-table-column v-for="c in xdColumns" :key="c.key" :label="c.label" width="118" align="center">
<template #default="{ row }">
<el-input-number
v-model="row[c.key]"
:min="0"
:precision="1"
:step="0.5"
:controls="false"
:class="['xd-input', 'xd-' + (xdLevel(row[c.key]) || 'normal')]"
@focus="selectAll"
@input="(v) => onXdInput(row, c.key, v)"
>
<template #suffix>
<el-icon v-if="xdLevel(row[c.key]) === 'teye'" class="xd-icon icon-teye"><CircleCloseFilled /></el-icon>
<el-icon v-else-if="xdLevel(row[c.key]) === 'daye'" class="xd-icon icon-daye"><WarningFilled /></el-icon>
<el-icon v-else-if="xdLevel(row[c.key]) === 'xiaoye'" class="xd-icon icon-xiaoye"><InfoFilled /></el-icon>
</template>
</el-input-number>
</template>
</el-table-column>
<el-table-column prop="xdNum" label="已称绞数" width="80" align="center" />
<el-table-column prop="xdAvg" label="平均纤度" width="85" align="center" />
<el-table-column prop="fanweiNum" label="范围" width="60" align="center" />
<el-table-column prop="xiaoyeNum" label="小野" width="60" align="center" />
<el-table-column prop="dayeNum" label="大野" width="60" align="center" />
<el-table-column prop="teyeNum" label="特野" width="60" align="center" />
<el-table-column prop="centerNum" label="中心" width="60" align="center" />
</el-table>
<!-- 汇总 -->
<div class="summary-bar">
<div class="stat"><span class="lbl">已称绞数</span><b>{{ summary.jiaoshu }}</b></div>
<div class="stat"><span class="lbl">平均纤度</span><b class="c-primary">{{ summary.avgxiandu }}</b></div>
<div class="stat"><span class="lbl">均方差</span><b>{{ summary.junfangcha }}</b></div>
<div class="stat"><span class="lbl">总差</span><b>{{ summary.zongcha }}</b></div>
<div class="stat"><span class="lbl">范纤率</span><b class="c-primary">{{ summary.fanxianLv }}%</b></div>
<div class="stat"><span class="lbl">范围</span><b class="c-blue">{{ summary.fanweiNum }}</b></div>
<div class="stat tag-xiaoye"><span class="lbl">小野</span><b>{{ summary.xiaoyeNum }}</b></div>
<div class="stat tag-daye"><span class="lbl">大野</span><b>{{ summary.dayeNum }}</b></div>
<div class="stat tag-teye"><span class="lbl">特野</span><b>{{ summary.teyeNum }}</b></div>
<div class="stat tag-center"><span class="lbl">中心</span><b>{{ summary.centerNum }}</b></div>
<div class="stat level">
<span class="lbl">等级</span>
<el-input v-model="editData.dengji" placeholder="等级" style="width: 90px" />
</div>
</div>
<!-- 图例 -->
<div class="legend">
<span class="lg lg-teye"><el-icon><CircleCloseFilled /></el-icon> 特野</span>
<span class="lg lg-daye"><el-icon><WarningFilled /></el-icon> 大野</span>
<span class="lg lg-xiaoye"><el-icon><InfoFilled /></el-icon> 小野</span>
</div>
</template>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.info-block { margin-bottom: 12px; }
/* 规格范围描述着色 */
.d-xiaoye :deep(.el-descriptions__content) { color: #b88230; font-weight: 600; }
.d-daye :deep(.el-descriptions__content) { color: #e6a23c; font-weight: 600; }
.d-teye :deep(.el-descriptions__content) { color: #f56c6c; font-weight: 600; }
/* 纤度输入框:浅色背景 + 图标 */
.xd-input :deep(.el-input__wrapper) { padding: 0 6px; }
.xd-input.xd-teye :deep(.el-input__wrapper) { background: #fef0f0; box-shadow: 0 0 0 1px #f56c6c inset; }
.xd-input.xd-daye :deep(.el-input__wrapper) { background: #fdf6ec; box-shadow: 0 0 0 1px #e6a23c inset; }
.xd-input.xd-xiaoye :deep(.el-input__wrapper) { background: #fef9e7; box-shadow: 0 0 0 1px #e6b23c inset; }
.xd-icon { font-size: 14px; }
.icon-teye { color: #f56c6c; }
.icon-daye { color: #e6a23c; }
.icon-xiaoye { color: #e6b23c; }
/* 汇总栏 */
.summary-bar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 16px; padding: 14px 16px; background: #f5f7fa; border-radius: 8px; }
.stat { display: flex; flex-direction: column; align-items: center; min-width: 64px; padding: 6px 12px; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.stat .lbl { font-size: 12px; color: #909399; }
.stat b { font-size: 18px; color: #303133; }
.stat .c-primary { color: #409eff; }
.stat .c-blue { color: #409eff; }
.stat.tag-xiaoye b { color: #b88230; }
.stat.tag-daye b { color: #e6a23c; }
.stat.tag-teye b { color: #f56c6c; }
.stat.tag-center b { color: #67c23a; }
.stat.level { flex-direction: row; gap: 6px; align-items: center; }
/* 图例 */
.legend { display: flex; gap: 18px; margin-top: 12px; color: #606266; font-size: 13px; }
.legend .lg { display: flex; align-items: center; gap: 4px; }
.lg-teye { color: #f56c6c; }
.lg-daye { color: #e6a23c; }
.lg-xiaoye { color: #b88230; }
</style>
+152
View File
@@ -0,0 +1,152 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('新增部门');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({ code: '', name: '', parentId: 0, manageType: 5, dutyType: 5, sortOrder: 0, description: '' });
const manageTypeMap = { 5: '公司', 10: '部门', 15: '车间', 20: '科室', 25: '岗位', 30: '虚拟' };
async function loadList() {
loading.value = true;
try {
const res = await request.get('/sys/department');
list.value = res.data;
} finally { loading.value = false; }
}
function parentName(id) {
return list.value.find(x => x.id === id)?.name || '-';
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增部门';
Object.assign(form, { code: '', name: '', parentId: 0, manageType: 5, dutyType: 5, sortOrder: 0, description: '' });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑部门';
Object.assign(form, {
code: row.code, name: row.name, parentId: row.parentId, manageType: row.manageType,
dutyType: row.dutyType, sortOrder: row.sortOrder, description: row.description || ''
});
dialogVisible.value = true;
}
async function handleSave() {
if (!form.code || !form.name) { ElMessage.warning('请填写编码和名称'); return; }
try {
if (isEdit.value) {
await request.put(`/sys/department/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/sys/department', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadList();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除部门「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/sys/department/${row.id}`);
ElMessage.success('删除成功');
loadList();
} catch (e) {}
}
async function handleActive(row) {
try {
await request.put(`/sys/department/${row.id}/active`, { active: !row.isActive });
ElMessage.success('操作成功');
loadList();
} catch (e) {}
}
onMounted(loadList);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>部门管理</span>
<el-button type="primary" @click="openCreate">新增部门</el-button>
</div>
</template>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="code" label="编码" width="140" />
<el-table-column prop="name" label="名称" width="160" />
<el-table-column label="上级" width="140">
<template #default="{ row }">{{ parentName(row.parentId) }}</template>
</el-table-column>
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ manageTypeMap[row.manageType] || row.manageType }}</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="description" label="描述" min-width="160" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'">{{ row.isActive ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.isActive ? 'warning' : 'success'" @click="handleActive(row)">
{{ row.isActive ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form :model="form" label-width="80px">
<el-form-item label="编码">
<el-input v-model="form.code" placeholder="部门编码" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="部门名称" />
</el-form-item>
<el-form-item label="上级">
<el-select v-model="form.parentId" clearable placeholder="无上级" style="width: 100%">
<el-option v-for="d in list" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="form.manageType" style="width: 100%">
<el-option v-for="(v, k) in manageTypeMap" :key="k" :label="v" :value="Number(k)" />
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
+163
View File
@@ -0,0 +1,163 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('新增菜单');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({ name: '', iconSvg: '', menuType: 2, parentId: 0, sortOrder: 0 });
const menuTypeMap = { 0: '默认', 1: '分组', 2: '页面' };
// 扁平化树形菜单(用于父菜单选择)
const flatMenus = computed(() => {
const result = [];
const walk = (items) => {
items.forEach((i) => {
result.push(i);
if (i.items && i.items.length) walk(i.items);
});
};
walk(list.value);
return result;
});
async function loadList() {
loading.value = true;
try {
const res = await request.get('/sys/menu/tree');
list.value = res.data;
} finally { loading.value = false; }
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增菜单';
Object.assign(form, { name: '', iconSvg: '', menuType: 2, parentId: 0, sortOrder: 0 });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑菜单';
Object.assign(form, {
name: row.name, iconSvg: row.iconSvg, menuType: menuTypeValue(row.menuType),
parentId: row.parentId, sortOrder: row.sortOrder
});
dialogVisible.value = true;
}
function menuTypeValue(t) {
if (t === '分组') return 1;
if (t === '页面') return 2;
return 0;
}
async function handleSave() {
if (!form.name) { ElMessage.warning('请填写菜单名称'); return; }
try {
if (isEdit.value) {
await request.put(`/sys/menu/${editId.value}`, { ...form, assemblyName: '', typeFullName: '' });
ElMessage.success('更新成功');
} else {
await request.post('/sys/menu', { ...form, assemblyName: '', typeFullName: '' });
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadList();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除菜单「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/sys/menu/${row.id}`);
ElMessage.success('删除成功');
loadList();
} catch (e) {}
}
async function handleActive(row) {
try {
await request.put(`/sys/menu/${row.id}/active`, { active: row.isActive !== '启用' });
ElMessage.success('操作成功');
loadList();
} catch (e) {}
}
onMounted(loadList);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>菜单管理</span>
<el-button type="primary" @click="openCreate">新增菜单</el-button>
</div>
</template>
<el-table v-loading="loading" :data="list" border stripe row-key="id" default-expand-all>
<el-table-column prop="name" label="名称" width="200" />
<el-table-column prop="code" label="编码" width="180" />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.menuType }}</template>
</el-table-column>
<el-table-column prop="iconSvg" label="图标" width="100" />
<el-table-column prop="parentName" label="上级" width="140" />
<el-table-column prop="level" label="层级" width="70" />
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.isActive === '启用' ? 'success' : 'danger'">{{ row.isActive }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.isActive === '启用' ? 'warning' : 'success'" @click="handleActive(row)">
{{ row.isActive === '启用' ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="菜单名称" />
</el-form-item>
<el-form-item label="图标">
<el-input v-model="form.iconSvg" placeholder="Element Plus 图标名" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="form.menuType" style="width: 100%">
<el-option v-for="(v, k) in menuTypeMap" :key="k" :label="v" :value="Number(k)" />
</el-select>
</el-form-item>
<el-form-item label="上级">
<el-select v-model="form.parentId" clearable placeholder="无上级" style="width: 100%">
<el-option v-for="m in flatMenus" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
+318
View File
@@ -0,0 +1,318 @@
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('新增角色');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({ name: '', code: '', sortOrder: 0, description: '' });
async function loadList() {
loading.value = true;
try {
const res = await request.get('/sys/role');
list.value = res.data;
} finally { loading.value = false; }
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增角色';
Object.assign(form, { name: '', code: '', sortOrder: 0, description: '' });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑角色';
Object.assign(form, { name: row.name, code: row.code, sortOrder: row.sortOrder, description: row.description });
dialogVisible.value = true;
}
async function handleSave() {
if (!form.name || !form.code) { ElMessage.warning('请填写名称和编码'); return; }
try {
if (isEdit.value) {
await request.put(`/sys/role/${editId.value}`, form);
ElMessage.success('更新成功');
} else {
await request.post('/sys/role', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadList();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除角色「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/sys/role/${row.id}`);
ElMessage.success('删除成功');
loadList();
} catch (e) {}
}
async function handleActive(row) {
try {
await request.put(`/sys/role/${row.id}/active`, { active: !row.isActive });
ElMessage.success('操作成功');
loadList();
} catch (e) {}
}
// ==================== 权限分配(参照 F9 Page_Role_DepPermission 用法) ====================
const RESOURCE_TYPE = { menu: 1, department: 4, subscribe: 5 };
const permDialogVisible = ref(false);
const currentRole = ref(null);
const activeTab = ref('menu');
const loadingPerm = ref(false);
const savingPerm = ref(false);
const menuTree = ref([]);
const deptTree = ref([]);
const subTree = ref([]);
const menuTreeRef = ref();
const deptTreeRef = ref();
const subTreeRef = ref();
// 角色已分配权限:ResourceId -> rolePermissionId
const rolePerms = reactive({ menu: [], department: [], subscribe: [] });
function toMenuTree(nodes) {
return (nodes || []).map(n => ({
id: n.id,
label: n.name,
code: n.code,
children: n.items ? toMenuTree(n.items) : [],
}));
}
function buildDeptTreeLocal(listData) {
const map = {};
(listData || []).forEach(d => {
map[d.id] = { id: d.id, label: d.name, code: d.code, children: [] };
});
const roots = [];
(listData || []).forEach(d => {
const node = map[d.id];
if (d.parentId && map[d.parentId]) map[d.parentId].children.push(node);
else roots.push(node);
});
return roots;
}
async function openPerm(row) {
currentRole.value = row;
activeTab.value = 'menu';
permDialogVisible.value = true;
await loadPermData(row.id);
}
async function loadPermData(roleId) {
loadingPerm.value = true;
try {
const [menuRes, deptRes, subRes, menuPerms, deptPerms, subPerms] = await Promise.all([
request.get('/sys/menu/tree'),
request.get('/sys/department'),
request.get('/notification/type'),
request.get(`/sys/role/${roleId}/permissions`, { params: { resourceType: RESOURCE_TYPE.menu } }),
request.get(`/sys/role/${roleId}/permissions`, { params: { resourceType: RESOURCE_TYPE.department } }),
request.get(`/sys/role/${roleId}/permissions`, { params: { resourceType: RESOURCE_TYPE.subscribe } }),
]);
rolePerms.menu = menuPerms.data || [];
rolePerms.department = deptPerms.data || [];
rolePerms.subscribe = subPerms.data || [];
menuTree.value = toMenuTree(menuRes.data);
deptTree.value = buildDeptTreeLocal(deptRes.data);
subTree.value = (subRes.data || []).map(t => ({ id: t.id, label: t.name, code: t.code }));
// 树渲染后设置勾选(自动勾选角色已有权限)
await nextTick();
menuTreeRef.value?.setCheckedKeys(rolePerms.menu.map(p => String(p.resourceId)));
deptTreeRef.value?.setCheckedKeys(rolePerms.department.map(p => String(p.resourceId)));
subTreeRef.value?.setCheckedKeys(rolePerms.subscribe.map(p => String(p.resourceId)));
} finally {
loadingPerm.value = false;
}
}
// 收集树中所有节点(用于按 id 查 code)
function collectNodes(nodes, result) {
(nodes || []).forEach(n => { result.push(n); if (n.children) collectNodes(n.children, result); });
}
async function handlePermSave() {
const roleId = currentRole.value?.id;
if (!roleId) return;
savingPerm.value = true;
try {
const menuKeys = (menuTreeRef.value?.getCheckedKeys() || []).map(String);
const deptKeys = (deptTreeRef.value?.getCheckedKeys() || []).map(String);
const subKeys = (subTreeRef.value?.getCheckedKeys() || []).map(String);
await savePermType('menu', RESOURCE_TYPE.menu, menuKeys, menuTree.value);
await savePermType('department', RESOURCE_TYPE.department, deptKeys, deptTree.value);
await savePermType('subscribe', RESOURCE_TYPE.subscribe, subKeys, subTree.value);
ElMessage.success('权限保存成功');
permDialogVisible.value = false;
loadList();
} finally {
savingPerm.value = false;
}
}
async function savePermType(type, resourceType, checkedKeys, treeData) {
const checkedIds = new Set(checkedKeys.map(String));
const original = rolePerms[type];
// 构建 id -> node 映射(用于取 code)
const nodes = [];
collectNodes(treeData, nodes);
const nodeMap = new Map(nodes.map(n => [String(n.id), n]));
// 新增:当前勾选但原始没有
for (const key of checkedKeys) {
if (!original.some(p => String(p.resourceId) === key)) {
const node = nodeMap.get(key);
await request.post(`/sys/role/${currentRole.value.id}/permissions`, {
permissionType: 1, // Visble
resourceType,
resourceIdentity: node?.code || key,
resourceId: Number(key),
});
}
}
// 删除:原始有但当前未勾选
for (const rp of original) {
if (!checkedIds.has(String(rp.resourceId))) {
await request.delete(`/sys/role/${currentRole.value.id}/permissions/${rp.id}`, {
params: { resourceType },
});
}
}
}
onMounted(loadList);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>角色管理</span>
<el-button type="primary" @click="openCreate">新增角色</el-button>
</div>
</template>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="name" label="名称" width="140" />
<el-table-column prop="code" label="编码" width="160" />
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="menuPermissionCount" label="菜单权限" width="90" align="center" />
<el-table-column prop="depPermissionCount" label="部门权限" width="90" align="center" />
<el-table-column prop="description" label="描述" min-width="140" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'">{{ row.isActive ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" @click="openPerm(row)">分配权限</el-button>
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.isActive ? 'warning' : 'success'" @click="handleActive(row)">
{{ row.isActive ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="如 系统管理员" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.code" placeholder="如 admin" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
<!-- 权限分配对话框(菜单 / 部门 / 消息订阅,带确认按钮) -->
<el-dialog v-model="permDialogVisible" :title="`分配权限 - ${currentRole?.name || ''}`" width="520px">
<div v-loading="loadingPerm">
<el-radio-group v-model="activeTab" class="perm-tabs">
<el-radio-button value="menu">菜单权限</el-radio-button>
<el-radio-button value="department">部门权限</el-radio-button>
<el-radio-button value="subscribe">消息订阅</el-radio-button>
</el-radio-group>
<div v-show="activeTab === 'menu'" class="perm-panel">
<el-tree
ref="menuTreeRef"
:data="menuTree"
show-checkbox
check-strictly
node-key="id"
default-expand-all
:props="{ label: 'label', children: 'children' }"
/>
</div>
<div v-show="activeTab === 'department'" class="perm-panel">
<el-tree
ref="deptTreeRef"
:data="deptTree"
show-checkbox
check-strictly
node-key="id"
default-expand-all
:props="{ label: 'label', children: 'children' }"
/>
</div>
<div v-show="activeTab === 'subscribe'" class="perm-panel">
<el-tree
ref="subTreeRef"
:data="subTree"
show-checkbox
check-strictly
node-key="id"
default-expand-all
:props="{ label: 'label', children: 'children' }"
/>
</div>
</div>
<template #footer>
<el-button @click="permDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingPerm" @click="handlePermSave">确认</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.perm-tabs { margin-bottom: 12px; }
.perm-panel { max-height: 420px; overflow-y: auto; border: 1px solid #ebeef5; border-radius: 4px; padding: 8px; }
</style>
+236
View File
@@ -0,0 +1,236 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { buildDeptTree } from '../../../utils/dept';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const deptTree = ref([]);
const roles = ref([]);
const userRoles = ref([]);
const query = reactive({ departmentId: 0, like: '', page: 1, pageSize: 10 });
const dialogVisible = ref(false);
const dialogTitle = ref('新增用户');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({ userName: '', phoneNumber: '', loginName: '', departmentId: null, sortOrder: 0 });
const roleDialogVisible = ref(false);
const roleDialogUserId = ref(null);
async function loadList() {
loading.value = true;
try {
const res = await request.get('/sys/user', {
params: { departmentId: query.departmentId || 0, like: query.like, page: query.page, pageSize: query.pageSize }
});
list.value = res.data.items;
total.value = res.data.totalCount;
} finally {
loading.value = false;
}
}
async function loadDepts() {
const res = await request.get('/sys/department');
deptTree.value = buildDeptTree(res.data);
}
async function loadRoles() {
const res = await request.get('/sys/role');
roles.value = res.data;
}
function handleSearch() { query.page = 1; loadList(); }
function resetSearch() { Object.assign(query, { departmentId: 0, like: '', page: 1, pageSize: 10 }); loadList(); }
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增用户';
Object.assign(form, { userName: '', phoneNumber: '', loginName: '', departmentId: null, sortOrder: 0 });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑用户';
Object.assign(form, {
userName: row.userName, phoneNumber: row.phoneNumber, loginName: row.loginName,
departmentId: row.departmentId ?? null, sortOrder: row.sortOrder
});
dialogVisible.value = true;
}
async function handleSave() {
if (!form.userName || !form.loginName) {
ElMessage.warning('请填写姓名和登录账号');
return;
}
try {
if (isEdit.value) {
await request.put(`/sys/user/${editId.value}`, { ...form, departmentId: form.departmentId });
ElMessage.success('更新成功');
} else {
await request.post('/sys/user', { ...form, departmentId: form.departmentId });
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadList();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除用户「${row.userName}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/sys/user/${row.id}`);
ElMessage.success('删除成功');
loadList();
} catch (e) {}
}
async function handleActive(row) {
try {
await request.put(`/sys/user/${row.id}/active`, { active: !row.isActive });
ElMessage.success('操作成功');
loadList();
} catch (e) {}
}
async function handleResetPwd(row) {
await ElMessageBox.confirm(`为用户「${row.userName}」重置为默认密码?`, '重置密码', { type: 'warning' });
try {
await request.put(`/sys/user/${row.id}/reset-password`);
ElMessage.success('密码已重置');
} catch (e) {}
}
async function openAssignRole(row) {
roleDialogUserId.value = row.id;
const res = await request.get(`/sys/user/${row.id}/roles`);
userRoles.value = res.data.map(x => x.roleId);
roleDialogVisible.value = true;
}
async function handleSaveRoles() {
try {
const res = await request.get(`/sys/user/${roleDialogUserId.value}/roles`);
const existing = res.data.map(x => ({ roleId: x.roleId, id: x.id }));
// 移除未选中的
for (const e of existing) {
if (!userRoles.value.includes(e.roleId)) {
await request.delete(`/sys/user/${roleDialogUserId.value}/roles/${e.roleId}`);
}
}
// 新增选中的
for (const roleId of userRoles.value) {
if (!existing.some(e => e.roleId === roleId)) {
await request.post(`/sys/user/${roleDialogUserId.value}/roles`, { roleId });
}
}
ElMessage.success('角色分配成功');
roleDialogVisible.value = false;
loadList();
} catch (e) {}
}
function handlePageChange(page) { query.page = page; loadList(); }
onMounted(() => { loadList(); loadDepts(); loadRoles(); });
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>用户管理</span>
<el-button type="primary" @click="openCreate">新增用户</el-button>
</div>
</template>
<div class="search-bar">
<el-input v-model="query.like" placeholder="姓名/账号/手机号" clearable style="width: 200px" @keyup.enter="handleSearch" />
<el-tree-select v-model="query.departmentId" :data="deptTree" :props="{ label: 'name', value: 'id', children: 'children' }" check-strictly clearable placeholder="选择部门" style="width: 220px" />
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="userName" label="姓名" width="120" />
<el-table-column prop="loginName" label="账号" width="120" />
<el-table-column prop="phoneNumber" label="手机号" width="130" />
<el-table-column prop="departmentName" label="部门" width="120" />
<el-table-column prop="roleName" label="角色" min-width="140" />
<el-table-column prop="lastLoginAt" label="最后登录" width="160" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'danger'">{{ row.isActive ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" @click="openAssignRole(row)">角色</el-button>
<el-button size="small" @click="handleResetPwd(row)">重置密码</el-button>
<el-button size="small" :type="row.isActive ? 'warning' : 'success'" @click="handleActive(row)">
{{ row.isActive ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
:current-page="query.page"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form :model="form" label-width="90px">
<el-form-item label="姓名">
<el-input v-model="form.userName" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="登录账号">
<el-input v-model="form.loginName" placeholder="登录账号" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phoneNumber" placeholder="手机号" />
</el-form-item>
<el-form-item label="部门">
<el-tree-select v-model="form.departmentId" :data="deptTree" :props="{ label: 'name', value: 'id', children: 'children' }" check-strictly clearable placeholder="选择部门" style="width: 100%" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="roleDialogVisible" title="分配角色" width="400px">
<el-select v-model="userRoles" multiple placeholder="选择角色" style="width: 100%">
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveRoles">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination { margin-top: 16px; justify-content: flex-end; }
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const treeData = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('新增部门');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
name: '',
parentId: null,
leaderId: null,
phone: '',
sort: 0
});
const employees = ref([]);
async function loadTree() {
loading.value = true;
try {
const res = await request.get('/department/tree');
treeData.value = res.data;
} finally {
loading.value = false;
}
}
async function loadEmployees() {
const res = await request.get('/employee', { params: { pageIndex: 1, pageSize: 1000 } });
employees.value = res.data.items;
}
function openCreate(parentId = null) {
isEdit.value = false;
dialogTitle.value = '新增部门';
Object.assign(form, { name: '', parentId, leaderId: null, phone: '', sort: 0 });
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑部门';
Object.assign(form, {
name: row.name,
parentId: row.parentId,
leaderId: row.leaderId,
phone: row.phone,
sort: row.sort
});
dialogVisible.value = true;
}
async function handleSave() {
if (!form.name) {
ElMessage.warning('请输入部门名称');
return;
}
try {
if (isEdit.value) {
await request.put(`/department/${editId.value}`, { ...form, status: 1 });
ElMessage.success('更新成功');
} else {
await request.post('/department', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadTree();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除部门「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/department/${row.id}`);
ElMessage.success('删除成功');
loadTree();
} catch (e) {}
}
onMounted(() => {
loadTree();
loadEmployees();
});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>部门管理</span>
<el-button v-permission="'system:department:btn'" type="primary" @click="openCreate(null)">新增部门</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="treeData"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
>
<el-table-column prop="name" label="部门名称" min-width="200" />
<el-table-column prop="phone" label="联系电话" width="150" />
<el-table-column prop="leaderName" label="负责人" width="120" />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button v-permission="'system:department:btn'" size="small" @click="openCreate(row.id)">添加子部门</el-button>
<el-button v-permission="'system:department:btn'" size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-permission="'system:department:btn'" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form :model="form" label-width="90px">
<el-form-item label="部门名称">
<el-input v-model="form.name" placeholder="请输入部门名称" />
</el-form-item>
<el-form-item label="上级部门">
<el-tree-select
v-model="form.parentId"
:data="treeData"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
clearable
placeholder="不选则为顶级部门"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="form.leaderId" clearable placeholder="选择负责人" style="width: 100%">
<el-option
v-for="e in employees"
:key="e.id"
:label="e.realName"
:value="e.id"
/>
</el-select>
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const total = ref(0);
const loading = ref(false);
const deptTree = ref([]);
const roles = ref([]);
const query = reactive({
keyword: '',
departmentId: null,
status: null,
pageIndex: 1,
pageSize: 10
});
const dialogVisible = ref(false);
const dialogTitle = ref('新增职工');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
username: '',
password: '',
realName: '',
nickname: '',
gender: '男',
phone: '',
email: '',
departmentId: null,
remark: '',
roleIds: []
});
async function loadList() {
loading.value = true;
try {
const res = await request.get('/employee', { params: query });
list.value = res.data.items;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function loadDepts() {
const res = await request.get('/department/tree');
deptTree.value = res.data;
}
async function loadRoles() {
const res = await request.get('/role');
roles.value = res.data;
}
function handleSearch() {
query.pageIndex = 1;
loadList();
}
function resetSearch() {
Object.assign(query, { keyword: '', departmentId: null, status: null, pageIndex: 1, pageSize: 10 });
loadList();
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增职工';
Object.assign(form, {
username: '', password: '', realName: '', nickname: '', gender: '男',
phone: '', email: '', departmentId: null, remark: '', roleIds: []
});
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑职工';
Object.assign(form, {
username: row.username, password: '', realName: row.realName, nickname: row.nickname,
gender: row.gender, phone: row.phone, email: row.email,
departmentId: row.departmentId, remark: row.remark, roleIds: [...row.roleIds]
});
dialogVisible.value = true;
}
async function handleSave() {
try {
if (isEdit.value) {
const { username, password, ...updateData } = form;
await request.put(`/employee/${editId.value}`, updateData);
ElMessage.success('更新成功');
} else {
if (!form.username || !form.password || !form.realName) {
ElMessage.warning('请填写账号、密码、姓名');
return;
}
await request.post('/employee', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadList();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除职工「${row.realName}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/employee/${row.id}`);
ElMessage.success('删除成功');
loadList();
} catch (e) {}
}
async function handleResetPwd(row) {
const { value } = await ElMessageBox.prompt(`为「${row.realName}」重置密码`, '重置密码', {
inputType: 'password',
inputPattern: /^.{6,}$/,
inputErrorMessage: '密码至少6位'
});
try {
await request.put(`/employee/${row.id}/password`, { newPassword: value });
ElMessage.success('密码重置成功');
} catch (e) {}
}
function handlePageChange(page) {
query.pageIndex = page;
loadList();
}
onMounted(() => {
loadList();
loadDepts();
loadRoles();
});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>职工管理</span>
<el-button v-permission="'system:employee:btn'" type="primary" @click="openCreate">新增职工</el-button>
</div>
</template>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="query.keyword" placeholder="姓名/账号/手机号" clearable style="width: 200px" @keyup.enter="handleSearch" />
<el-tree-select
v-model="query.departmentId"
:data="deptTree"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
clearable
placeholder="选择部门"
style="width: 200px"
/>
<el-select v-model="query.status" clearable placeholder="状态" style="width: 120px">
<el-option label="在职" :value="1" />
<el-option label="离职" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="username" label="账号" width="120" />
<el-table-column prop="realName" label="姓名" width="120" />
<el-table-column prop="nickname" label="昵称" width="120" />
<el-table-column prop="gender" label="性别" width="70" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" min-width="160" />
<el-table-column prop="departmentName" label="部门" width="120" />
<el-table-column label="角色" min-width="140">
<template #default="{ row }">
<el-tag v-for="r in row.roleNames" :key="r" size="small" class="role-tag">{{ r }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '在职' : '离职' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button v-permission="'system:employee:btn'" size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-permission="'system:employee:btn'" size="small" @click="handleResetPwd(row)">重置密码</el-button>
<el-button v-permission="'system:employee:btn'" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" label-width="90px">
<el-form-item v-if="!isEdit" label="账号">
<el-input v-model="form.username" placeholder="登录账号" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码">
<el-input v-model="form.password" type="password" placeholder="初始密码(至少6位)" show-password />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="昵称" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.gender">
<el-radio value="男">男</el-radio>
<el-radio value="女">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="邮箱" />
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="form.departmentId"
:data="deptTree"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
placeholder="选择部门"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.roleIds" multiple placeholder="分配角色" style="width: 100%">
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.role-tag {
margin-right: 4px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div></div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup>
import { ref } from 'vue';
const logs = ref([]);
</script>
<template>
<el-card shadow="never">
<template #header>
<span>操作日志</span>
</template>
<el-empty description="暂无日志记录" />
</el-card>
</template>
+187
View File
@@ -0,0 +1,187 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const treeData = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('新增菜单');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
parentId: null,
name: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0
});
const typeOptions = [
{ label: '目录', value: 'directory' },
{ label: '菜单', value: 'menu' },
{ label: '按钮', value: 'button' }
];
async function loadTree() {
loading.value = true;
try {
const res = await request.get('/menu/tree');
treeData.value = res.data;
} finally {
loading.value = false;
}
}
function openCreate(parentId = null) {
isEdit.value = false;
dialogTitle.value = '新增菜单';
Object.assign(form, {
parentId, name: '', type: 'menu', path: '', component: '', icon: '', permission: '', sort: 0
});
dialogVisible.value = true;
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑菜单';
Object.assign(form, {
parentId: row.parentId,
name: row.name,
type: row.type,
path: row.path,
component: row.component,
icon: row.icon,
permission: row.permission,
sort: row.sort
});
dialogVisible.value = true;
}
async function handleSave() {
if (!form.name) {
ElMessage.warning('请输入菜单名称');
return;
}
try {
if (isEdit.value) {
await request.put(`/menu/${editId.value}`, { ...form, status: 1 });
ElMessage.success('更新成功');
} else {
await request.post('/menu', form);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadTree();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除菜单「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/menu/${row.id}`);
ElMessage.success('删除成功');
loadTree();
} catch (e) {}
}
onMounted(loadTree);
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>菜单管理</span>
<el-button v-permission="'system:menu:btn'" type="primary" @click="openCreate(null)">新增菜单</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="treeData"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
>
<el-table-column prop="name" label="菜单名称" min-width="180" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag :type="row.type === 'directory' ? 'warning' : row.type === 'menu' ? 'primary' : 'info'">
{{ row.type === 'directory' ? '目录' : row.type === 'menu' ? '菜单' : '按钮' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" min-width="150" />
<el-table-column prop="component" label="组件" min-width="160" />
<el-table-column prop="icon" label="图标" width="90" />
<el-table-column prop="permission" label="权限标识" min-width="160" />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button v-if="row.type !== 'button'" v-permission="'system:menu:btn'" size="small" @click="openCreate(row.id)">添加子菜单</el-button>
<el-button v-permission="'system:menu:btn'" size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-permission="'system:menu:btn'" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form :model="form" label-width="90px">
<el-form-item label="上级菜单">
<el-tree-select
v-model="form.parentId"
:data="treeData"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
clearable
placeholder="不选则为顶级"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单类型">
<el-radio-group v-model="form.type">
<el-radio-button v-for="t in typeOptions" :key="t.value" :value="t.value">
{{ t.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单名称">
<el-input v-model="form.name" placeholder="菜单名称" />
</el-form-item>
<el-form-item v-if="form.type !== 'button'" label="路由路径">
<el-input v-model="form.path" placeholder="/system/xxx" />
</el-form-item>
<el-form-item v-if="form.type === 'menu'" label="组件路径">
<el-input v-model="form.component" placeholder="system/xxx/index" />
</el-form-item>
<el-form-item v-if="form.type !== 'button'" label="图标">
<el-input v-model="form.icon" placeholder="Element Plus 图标名,如 User" />
</el-form-item>
<el-form-item label="权限标识">
<el-input v-model="form.permission" placeholder="如 system:user:list" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import request from '../../../utils/request';
import { ElMessage, ElMessageBox } from 'element-plus';
const list = ref([]);
const loading = ref(false);
const menuTree = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref('新增角色');
const isEdit = ref(false);
const editId = ref(null);
const form = reactive({
name: '',
code: '',
description: '',
sort: 0,
menuIds: []
});
// 菜单树 ref
const menuTreeRef = ref();
async function loadList() {
loading.value = true;
try {
const res = await request.get('/role');
list.value = res.data;
} finally {
loading.value = false;
}
}
async function loadMenuTree() {
const res = await request.get('/menu/tree');
menuTree.value = res.data;
}
function openCreate() {
isEdit.value = false;
dialogTitle.value = '新增角色';
Object.assign(form, { name: '', code: '', description: '', sort: 0, menuIds: [] });
dialogVisible.value = true;
nextTickClear();
}
function openEdit(row) {
isEdit.value = true;
editId.value = row.id;
dialogTitle.value = '编辑角色';
Object.assign(form, {
name: row.name,
code: row.code,
description: row.description,
sort: row.sort,
menuIds: []
});
dialogVisible.value = true;
// 加载该角色已分配的菜单
loadRoleMenus(row.id);
}
function nextTickClear() {
setTimeout(() => {
menuTreeRef.value?.setCheckedKeys([]);
}, 100);
}
async function loadRoleMenus(roleId) {
const res = await request.get(`/role/${roleId}/menus`);
form.menuIds = res.data;
setTimeout(() => {
menuTreeRef.value?.setCheckedKeys(res.data);
}, 100);
}
async function handleSave() {
if (!form.name || !form.code) {
ElMessage.warning('请填写角色名称和编码');
return;
}
const menuIds = menuTreeRef.value?.getCheckedKeys() || [];
try {
if (isEdit.value) {
await request.put(`/role/${editId.value}`, { ...form, menuIds });
ElMessage.success('更新成功');
} else {
await request.post('/role', { ...form, menuIds });
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadList();
} catch (e) {}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除角色「${row.name}」吗?`, '提示', { type: 'warning' });
try {
await request.delete(`/role/${row.id}`);
ElMessage.success('删除成功');
loadList();
} catch (e) {}
}
onMounted(() => {
loadList();
loadMenuTree();
});
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>角色管理</span>
<el-button v-permission="'system:role:btn'" type="primary" @click="openCreate">新增角色</el-button>
</div>
</template>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="name" label="角色名称" width="160" />
<el-table-column prop="code" label="角色编码" width="140" />
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="employeeCount" label="用户数" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-permission="'system:role:btn'" size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
v-permission="'system:role:btn'"
size="small"
type="danger"
:disabled="row.code === 'admin'"
@click="handleDelete(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form :model="form" label-width="90px">
<el-form-item label="角色名称">
<el-input v-model="form.name" placeholder="角色名称" />
</el-form-item>
<el-form-item label="角色编码">
<el-input v-model="form.code" placeholder="角色编码(唯一)" :disabled="isEdit" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="角色描述" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
<el-form-item label="菜单权限">
<el-tree
ref="menuTreeRef"
:data="menuTree"
show-checkbox
node-key="id"
:props="{ label: 'name', children: 'children' }"
default-expand-all
class="menu-tree"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.menu-tree {
max-height: 300px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
padding: 8px;
width: 100%;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
},
'/hubs': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true
}
}
}
});
Binary file not shown.