feat: 超级管理员前端完整开发 + UI问题修复
新增功能: - 超级管理员前端完整页面开发(账号管理/权限管理/审计日志/系统管理) - Mock适配器(支持独立运行无需后端) - 共享组件库(QueryPanel/DataTable/Pagination/ActionBar/Breadcrumb/StatusTag等) - AdminLayout布局(侧边栏/头部/标签页) - v-hasPermission自定义指令注册 - 路由守卫与权限控制 UI问题修复: - 注册v-hasPermission自定义指令(修复控制台警告) - DataTable显式导入DocumentDelete图标 - QueryPanel添加el-select/input最小宽度约束(修复下拉框文字截断) - 10个列表页主列从width改为min-width(修复宽屏右侧空白) - Pagination组件支持对象传参+独立传参双模式(修复分页不可见) - index.html viewport改为桌面端width=1920(去掉Pad适配) - el-link underline从boolean改为never(消除75条废弃API警告) 文档更新: - docs/07-前端界面开发规范.md 新增9.6浏览器渲染验证+9.7预防规则 - 功能清单/接口规范/技术要求等文档同步更新master
parent
da8b02b492
commit
d88b91f3c9
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
||||
VITE_API_MODE=mock
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
@ -0,0 +1,2 @@
|
||||
VITE_API_MODE=real
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=1920, initial-scale=1" />
|
||||
<title>物业维修SaaS管理后台 - 超级管理员</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "super-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.15.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.7",
|
||||
"pinia": "^2.3.1",
|
||||
"sass": "^1.99.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.4",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@ -0,0 +1,39 @@
|
||||
import type { IAccountApi } from '../types/account'
|
||||
import { mockAccountApi } from './mock/account'
|
||||
import type { IPermissionApi } from '../types/permission'
|
||||
import { mockPermissionApi } from './mock/permission'
|
||||
import type { ISystemApi, IAuditLogApi } from '../types/system'
|
||||
import { mockSystemApi, mockAuditLogApi } from './mock/system'
|
||||
|
||||
export type ApiMode = 'mock' | 'real'
|
||||
|
||||
class ApiFactory {
|
||||
private mode: ApiMode = (import.meta.env.VITE_API_MODE as ApiMode) || 'mock'
|
||||
|
||||
get accountApi(): IAccountApi {
|
||||
if (this.mode === 'mock') return mockAccountApi as unknown as IAccountApi
|
||||
// TODO: real API adapter
|
||||
return mockAccountApi as unknown as IAccountApi
|
||||
}
|
||||
|
||||
get permissionApi(): IPermissionApi {
|
||||
if (this.mode === 'mock') return mockPermissionApi as unknown as IPermissionApi
|
||||
return mockPermissionApi as unknown as IPermissionApi
|
||||
}
|
||||
|
||||
get systemApi(): ISystemApi {
|
||||
if (this.mode === 'mock') return mockSystemApi as unknown as ISystemApi
|
||||
return mockSystemApi as unknown as ISystemApi
|
||||
}
|
||||
|
||||
get auditLogApi(): IAuditLogApi {
|
||||
if (this.mode === 'mock') return mockAuditLogApi as unknown as IAuditLogApi
|
||||
return mockAuditLogApi as unknown as IAuditLogApi
|
||||
}
|
||||
}
|
||||
|
||||
export const apiFactory = new ApiFactory()
|
||||
export const accountApi = apiFactory.accountApi
|
||||
export const permissionApi = apiFactory.permissionApi
|
||||
export const systemApi = apiFactory.systemApi
|
||||
export const auditLogApi = apiFactory.auditLogApi
|
||||
@ -0,0 +1,357 @@
|
||||
import type { PageResponse } from '../types/common'
|
||||
import type {
|
||||
Hospital, HospitalQuery, HospitalFormData,
|
||||
PropertyCompany, PropertyCompanyQuery, PropertyCompanyFormData,
|
||||
HospitalAccount, HospitalAccountQuery, HospitalAccountFormData,
|
||||
PropertyAccount, PropertyAccountQuery, PropertyAccountFormData,
|
||||
ExpiringAccount, ExpiringAccountQuery, ExpiringStats,
|
||||
ExpiryReminderConfig
|
||||
} from '../types/account'
|
||||
|
||||
// ===== Mock数据 =====
|
||||
|
||||
const hospitalList: Hospital[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `hosp-${i + 1}`,
|
||||
name: ['北京协和医院', '上海瑞金医院', '广州中山大学附属医院', '四川华西医院', '武汉同济医院', '南京鼓楼医院', '浙江大学附属医院', '湘雅医院', '山东省立医院', '天津医科大学总医院', '西安交大一附院', '重庆医科大学附属医院', '郑州大学一附院', '哈尔滨医科大学附属医院', '吉林大学一院', '安徽省立医院', '福建省立医院', '南昌大学一附院', '河北医科大学二院', '广西医科大学一附院', '昆明医科大学一附院', '兰州大学一院', '贵州医科大学附属医院', '内蒙古医科大学附属医院', '新疆医科大学一附院'][i],
|
||||
creditCode: `91110000${String(10000000 + i * 1357911).slice(0, 8)}${String(i + 10).padStart(2, '0')}`,
|
||||
address: `${['北京市', '上海市', '广州市', '成都市', '武汉市', '南京市', '杭州市', '长沙市', '济南市', '天津市', '西安市', '重庆市', '郑州市', '哈尔滨市', '长春市', '合肥市', '福州市', '南昌市', '石家庄市', '南宁市', '昆明市', '兰州市', '贵阳市', '呼和浩特市', '乌鲁木齐市'][i]}XX路${i + 1}号`,
|
||||
contactPerson: ['张伟', '王芳', '李强', '赵敏', '刘洋', '陈静', '杨光', '周丽', '吴明', '郑华', '孙磊', '马超', '朱俊', '胡波', '林燕', '何勇', '高红', '罗杰', '谢军', '韩冰', '唐亮', '曹敏', '邓涛', '许峰', '范晶'][i],
|
||||
contactPhone: `1${3 + (i % 7)}${String(10000000 + i * 1234567).slice(0, 8)}`,
|
||||
status: i === 5 || i === 12 ? 'disabled' as const : 'enabled' as const,
|
||||
campusCount: i < 5 ? 3 : i < 10 ? 2 : 1,
|
||||
campuses: [
|
||||
{ id: `campus-${i + 1}-1`, name: '主院区', address: `主院区地址${i + 1}号`, contactPerson: ['张伟', '王芳', '李强'][i % 3] },
|
||||
...(i < 10 ? [{ id: `campus-${i + 1}-2`, name: '东院区', address: `东院区地址${i + 1}号`, contactPerson: ['赵敏', '刘洋'][i % 2] }] : []),
|
||||
...(i < 5 ? [{ id: `campus-${i + 1}-3`, name: '西院区', address: `西院区地址${i + 1}号`, contactPerson: '陈静' }] : [])
|
||||
],
|
||||
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 09:00:00`,
|
||||
updatedAt: `2026-${String((i % 3) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 10:00:00`
|
||||
}))
|
||||
|
||||
const propertyCompanyList: PropertyCompany[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `prop-co-${i + 1}`,
|
||||
name: ['万科物业', '绿城服务', '保利物业', '碧桂园服务', '中海物业', '龙湖智创', '金地物业', '融创服务', '招商积余', '华润万象', '新城悦服务', '永升服务', '雅生活', '时代邻里', '卓越商企', '彩生活', '中奥到家', '建业新生活', '弘阳服务', '鑫苑服务'][i],
|
||||
address: `${['北京市', '上海市', '广州市', '深圳市', '杭州市', '成都市', '南京市', '武汉市', '重庆市', '西安市'][i % 10]}XX大道${i + 100}号`,
|
||||
contactPerson: ['王建国', '李美玲', '张志远', '陈晓峰', '刘德明', '赵丽华', '周大伟', '吴秀英', '郑强', '马丽', '孙国强', '朱美凤', '胡志刚', '林翠花', '何建明', '高小红', '罗建军', '谢美玲', '韩志伟', '曹丽华'][i],
|
||||
contactPhone: `1${5 + (i % 5)}${String(20000000 + i * 2345678).slice(0, 8)}`,
|
||||
status: i === 3 || i === 8 ? 'disabled' as const : 'enabled' as const,
|
||||
serviceHospitals: hospitalList.slice(i % 5, (i % 5) + 2 + (i % 3)).map(h => h.name),
|
||||
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 08:00:00`,
|
||||
updatedAt: `2026-${String((i % 4) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 14:00:00`
|
||||
}))
|
||||
|
||||
const hospitalAccountList: HospitalAccount[] = Array.from({ length: 25 }, (_, i) => {
|
||||
const expireDate = new Date()
|
||||
expireDate.setDate(expireDate.getDate() + (i < 5 ? -30 + i * 5 : i < 15 ? 7 + i * 2 : 60 + i * 5))
|
||||
return {
|
||||
id: `hacc-${i + 1}`,
|
||||
username: `hospital${String(i + 1).padStart(2, '0')}`,
|
||||
hospitalId: hospitalList[i % hospitalList.length].id,
|
||||
hospitalName: hospitalList[i % hospitalList.length].name,
|
||||
roles: [`role-hosp-${(i % 3) + 1}`],
|
||||
roleNames: [['医院查看模板', '医院管理模板', '医院审批模板'][i % 3]],
|
||||
expireDate: expireDate.toISOString().split('T')[0],
|
||||
status: i < 3 ? 'expired' as const : i < 8 ? 'expiring' as const : i === 20 ? 'stopped' as const : 'normal' as const,
|
||||
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-15 10:00:00`
|
||||
}
|
||||
})
|
||||
|
||||
const propertyAccountList: PropertyAccount[] = Array.from({ length: 25 }, (_, i) => {
|
||||
const expireDate = new Date()
|
||||
expireDate.setDate(expireDate.getDate() + (i < 4 ? -20 + i * 5 : i < 12 ? 10 + i * 3 : 90 + i * 5))
|
||||
return {
|
||||
id: `pacc-${i + 1}`,
|
||||
username: `propadmin${String(i + 1).padStart(2, '0')}`,
|
||||
propertyCompanyId: propertyCompanyList[i % propertyCompanyList.length].id,
|
||||
propertyName: propertyCompanyList[i % propertyCompanyList.length].name,
|
||||
hospitalId: hospitalList[i % hospitalList.length].id,
|
||||
hospitalName: hospitalList[i % hospitalList.length].name,
|
||||
roles: [`role-prop-${(i % 4) + 1}`],
|
||||
roleNames: [['物业管理员模板', '主管模板', '班组长模板', '维修员模板'][i % 4]],
|
||||
expireDate: expireDate.toISOString().split('T')[0],
|
||||
status: i < 2 ? 'expired' as const : i < 6 ? 'expiring' as const : i === 18 ? 'stopped' as const : 'normal' as const,
|
||||
createdAt: `2025-${String((i % 12) + 1).padStart(2, '0')}-20 11:00:00`
|
||||
}
|
||||
})
|
||||
|
||||
const expiringAccountList: ExpiringAccount[] = [
|
||||
...hospitalAccountList.filter(a => a.status === 'expired' || a.status === 'expiring').map(a => ({
|
||||
id: a.id,
|
||||
username: a.username,
|
||||
accountType: 'hospital' as const,
|
||||
bindUnit: a.hospitalName,
|
||||
expireDate: a.expireDate,
|
||||
remainDays: Math.ceil((new Date(a.expireDate).getTime() - Date.now()) / 86400000),
|
||||
status: a.status
|
||||
})),
|
||||
...propertyAccountList.filter(a => a.status === 'expired' || a.status === 'expiring').map(a => ({
|
||||
id: a.id,
|
||||
username: a.username,
|
||||
accountType: 'property_admin' as const,
|
||||
bindUnit: a.propertyName,
|
||||
expireDate: a.expireDate,
|
||||
remainDays: Math.ceil((new Date(a.expireDate).getTime() - Date.now()) / 86400000),
|
||||
status: a.status
|
||||
}))
|
||||
]
|
||||
|
||||
let reminderConfig: ExpiryReminderConfig = {
|
||||
reminderDays: [7, 15, 30],
|
||||
loginPopup: true,
|
||||
popupCloseAction: 'normal',
|
||||
expiredAction: 'block'
|
||||
}
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function filterList<T>(list: T[], query: Record<string, any>, page: number, pageSize: number): PageResponse<T> {
|
||||
let filtered = [...list]
|
||||
if (query.name) filtered = filtered.filter((item: any) => item.name?.includes(query.name))
|
||||
if (query.username) filtered = filtered.filter((item: any) => item.username?.includes(query.username))
|
||||
if (query.contactPerson) filtered = filtered.filter((item: any) => item.contactPerson?.includes(query.contactPerson))
|
||||
if (query.status && query.status !== '') filtered = filtered.filter((item: any) => item.status === query.status)
|
||||
if (query.hospitalId) filtered = filtered.filter((item: any) => item.hospitalId === query.hospitalId)
|
||||
if (query.propertyCompanyId) filtered = filtered.filter((item: any) => item.propertyCompanyId === query.propertyCompanyId)
|
||||
if (query.accountType && query.accountType !== '') filtered = filtered.filter((item: any) => item.accountType === query.accountType)
|
||||
if (query.expireFilter) {
|
||||
const now = new Date()
|
||||
filtered = filtered.filter((item: any) => {
|
||||
const exp = new Date(item.expireDate)
|
||||
const diff = (exp.getTime() - now.getTime()) / 86400000
|
||||
if (query.expireFilter === 'expired') return diff < 0
|
||||
if (query.expireFilter === '7days') return diff >= 0 && diff <= 7
|
||||
if (query.expireFilter === '30days') return diff >= 0 && diff <= 30
|
||||
if (query.expireFilter === 'normal') return diff > 30
|
||||
return true
|
||||
})
|
||||
}
|
||||
if (query.expireStatus) {
|
||||
const now = new Date()
|
||||
filtered = filtered.filter((item: any) => {
|
||||
const diff = (new Date(item.expireDate).getTime() - now.getTime()) / 86400000
|
||||
if (query.expireStatus === 'expired') return diff < 0
|
||||
if (query.expireStatus === '7days') return diff >= 0 && diff <= 7
|
||||
if (query.expireStatus === '30days') return diff >= 0 && diff <= 30
|
||||
return true
|
||||
})
|
||||
}
|
||||
if (query.operator) filtered = filtered.filter((item: any) => item.operator?.includes(query.operator))
|
||||
if (query.operationType && query.operationType !== '') filtered = filtered.filter((item: any) => item.operationType === query.operationType)
|
||||
|
||||
const total = filtered.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const startIdx = (page - 1) * pageSize
|
||||
const pagedList = filtered.slice(startIdx, startIdx + pageSize)
|
||||
|
||||
return { list: pagedList, pagination: { page, pageSize, total, totalPages } }
|
||||
}
|
||||
|
||||
// ===== Mock API实现 =====
|
||||
|
||||
export const mockAccountApi = {
|
||||
async getHospitalList(query: HospitalQuery): Promise<PageResponse<Hospital>> {
|
||||
await delay(300)
|
||||
if (query._mockError === 'empty') return { list: [], pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }
|
||||
if (query._mockError === 'timeout') { await delay(16000); throw new Error('timeout') }
|
||||
if (query._mockError === '500') throw new Error('服务器内部错误')
|
||||
return filterList(hospitalList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async getHospitalDetail(id: string): Promise<Hospital> {
|
||||
await delay(200)
|
||||
const hospital = hospitalList.find(h => h.id === id)
|
||||
if (!hospital) throw new Error('医院不存在')
|
||||
return { ...hospital }
|
||||
},
|
||||
|
||||
async createHospital(data: HospitalFormData): Promise<{ id: string }> {
|
||||
await delay(500)
|
||||
const id = `hosp-${Date.now()}`
|
||||
hospitalList.unshift({
|
||||
id,
|
||||
name: data.name,
|
||||
creditCode: data.creditCode,
|
||||
address: data.address,
|
||||
contactPerson: data.contactPerson,
|
||||
contactPhone: data.contactPhone,
|
||||
status: data.status,
|
||||
campusCount: data.campuses.length,
|
||||
campuses: data.campuses,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
return { id }
|
||||
},
|
||||
|
||||
async updateHospital(id: string, data: HospitalFormData): Promise<void> {
|
||||
await delay(500)
|
||||
const idx = hospitalList.findIndex(h => h.id === id)
|
||||
if (idx === -1) throw new Error('医院不存在')
|
||||
hospitalList[idx] = {
|
||||
...hospitalList[idx],
|
||||
...data,
|
||||
campusCount: data.campuses.length,
|
||||
campuses: data.campuses,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
|
||||
async toggleHospitalStatus(id: string): Promise<void> {
|
||||
await delay(400)
|
||||
const hospital = hospitalList.find(h => h.id === id)
|
||||
if (!hospital) throw new Error('医院不存在')
|
||||
hospital.status = hospital.status === 'enabled' ? 'disabled' : 'enabled'
|
||||
},
|
||||
|
||||
async getPropertyCompanyList(query: PropertyCompanyQuery): Promise<PageResponse<PropertyCompany>> {
|
||||
await delay(300)
|
||||
if (query._mockError === 'empty') return { list: [], pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }
|
||||
return filterList(propertyCompanyList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async getPropertyCompanyDetail(id: string): Promise<PropertyCompany> {
|
||||
await delay(200)
|
||||
const company = propertyCompanyList.find(c => c.id === id)
|
||||
if (!company) throw new Error('物业公司不存在')
|
||||
return { ...company }
|
||||
},
|
||||
|
||||
async createPropertyCompany(data: PropertyCompanyFormData): Promise<{ id: string }> {
|
||||
await delay(500)
|
||||
const id = `prop-co-${Date.now()}`
|
||||
propertyCompanyList.unshift({
|
||||
id,
|
||||
name: data.name,
|
||||
address: data.address,
|
||||
contactPerson: data.contactPerson,
|
||||
contactPhone: data.contactPhone,
|
||||
status: 'enabled',
|
||||
serviceHospitals: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
return { id }
|
||||
},
|
||||
|
||||
async updatePropertyCompany(id: string, data: PropertyCompanyFormData): Promise<void> {
|
||||
await delay(500)
|
||||
const idx = propertyCompanyList.findIndex(c => c.id === id)
|
||||
if (idx === -1) throw new Error('物业公司不存在')
|
||||
propertyCompanyList[idx] = { ...propertyCompanyList[idx], ...data, updatedAt: new Date().toISOString() }
|
||||
},
|
||||
|
||||
async togglePropertyCompanyStatus(id: string): Promise<void> {
|
||||
await delay(400)
|
||||
const company = propertyCompanyList.find(c => c.id === id)
|
||||
if (!company) throw new Error('物业公司不存在')
|
||||
company.status = company.status === 'enabled' ? 'disabled' : 'enabled'
|
||||
},
|
||||
|
||||
async getHospitalAccountList(query: HospitalAccountQuery): Promise<PageResponse<HospitalAccount>> {
|
||||
await delay(300)
|
||||
return filterList(hospitalAccountList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async createHospitalAccount(data: HospitalAccountFormData): Promise<{ id: string }> {
|
||||
await delay(500)
|
||||
const id = `hacc-${Date.now()}`
|
||||
const hospital = hospitalList.find(h => h.id === data.hospitalId)
|
||||
hospitalAccountList.unshift({
|
||||
id,
|
||||
username: data.username,
|
||||
hospitalId: data.hospitalId,
|
||||
hospitalName: hospital?.name || '',
|
||||
roles: data.roleIds,
|
||||
roleNames: data.roleIds.map(() => '医院查看模板'),
|
||||
expireDate: data.expireDate,
|
||||
status: 'normal',
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
return { id }
|
||||
},
|
||||
|
||||
async toggleAccountStatus(id: string): Promise<void> {
|
||||
await delay(400)
|
||||
const account = [...hospitalAccountList, ...propertyAccountList].find(a => a.id === id)
|
||||
if (account) account.status = account.status === 'normal' ? 'stopped' : 'normal'
|
||||
},
|
||||
|
||||
async renewAccount(id: string, expireDate: string): Promise<void> {
|
||||
await delay(400)
|
||||
const account = [...hospitalAccountList, ...propertyAccountList].find(a => a.id === id)
|
||||
if (account) {
|
||||
account.expireDate = expireDate
|
||||
account.status = 'normal'
|
||||
}
|
||||
},
|
||||
|
||||
async resetPassword(id: string): Promise<{ newPassword: string }> {
|
||||
await delay(400)
|
||||
const pwd = 'Abc' + Math.random().toString(36).slice(2, 8)
|
||||
return { newPassword: pwd }
|
||||
},
|
||||
|
||||
async getPropertyAccountList(query: PropertyAccountQuery): Promise<PageResponse<PropertyAccount>> {
|
||||
await delay(300)
|
||||
return filterList(propertyAccountList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async createPropertyAccount(data: PropertyAccountFormData): Promise<{ id: string }> {
|
||||
await delay(500)
|
||||
const id = `pacc-${Date.now()}`
|
||||
const company = propertyCompanyList.find(c => c.id === data.propertyCompanyId)
|
||||
const hospital = hospitalList.find(h => h.id === data.hospitalId)
|
||||
propertyAccountList.unshift({
|
||||
id,
|
||||
username: data.username,
|
||||
propertyCompanyId: data.propertyCompanyId,
|
||||
propertyName: company?.name || '',
|
||||
hospitalId: data.hospitalId,
|
||||
hospitalName: hospital?.name || '',
|
||||
roles: data.roleIds,
|
||||
roleNames: data.roleIds.map(() => '物业管理员模板'),
|
||||
expireDate: data.expireDate,
|
||||
status: 'normal',
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
return { id }
|
||||
},
|
||||
|
||||
async getExpiringAccountList(query: ExpiringAccountQuery): Promise<PageResponse<ExpiringAccount>> {
|
||||
await delay(300)
|
||||
return filterList(expiringAccountList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async getExpiringStats(): Promise<ExpiringStats> {
|
||||
await delay(200)
|
||||
return {
|
||||
expired: expiringAccountList.filter(a => a.remainDays < 0).length,
|
||||
expiringIn7Days: expiringAccountList.filter(a => a.remainDays >= 0 && a.remainDays <= 7).length,
|
||||
expiringIn30Days: expiringAccountList.filter(a => a.remainDays >= 0 && a.remainDays <= 30).length
|
||||
}
|
||||
},
|
||||
|
||||
async getExpiryReminderConfig(): Promise<ExpiryReminderConfig> {
|
||||
await delay(200)
|
||||
return { ...reminderConfig }
|
||||
},
|
||||
|
||||
async saveExpiryReminderConfig(data: ExpiryReminderConfig): Promise<void> {
|
||||
await delay(500)
|
||||
reminderConfig = { ...data }
|
||||
},
|
||||
|
||||
async getHospitalOptions(): Promise<{ id: string; name: string }[]> {
|
||||
await delay(100)
|
||||
return hospitalList.filter(h => h.status === 'enabled').map(h => ({ id: h.id, name: h.name }))
|
||||
},
|
||||
|
||||
async getPropertyCompanyOptions(): Promise<{ id: string; name: string }[]> {
|
||||
await delay(100)
|
||||
return propertyCompanyList.filter(c => c.status === 'enabled').map(c => ({ id: c.id, name: c.name }))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,320 @@
|
||||
import type { PageResponse } from '../types/common'
|
||||
import type {
|
||||
Role, RoleQuery, RoleFormData,
|
||||
PermissionNode,
|
||||
PermissionRegistryItem, PermissionRegistryQuery,
|
||||
PermissionAuditLog, PermissionAuditLogQuery, PermissionAuditDetail
|
||||
} from '../types/permission'
|
||||
|
||||
// ===== Mock数据 =====
|
||||
|
||||
const permissionTree: PermissionNode[] = [
|
||||
{
|
||||
code: 'menu:repair', name: '在线报修', type: 'menu',
|
||||
children: [
|
||||
{
|
||||
code: 'page:repair:list', name: '工单列表', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:repair:order-manage', name: '工单管理', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:repair:order:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:repair:order:create', name: '新增', type: 'action' },
|
||||
{ code: 'action:repair:order:update', name: '编辑', type: 'action' },
|
||||
{ code: 'action:repair:order:delete', name: '删除', type: 'action' },
|
||||
{ code: 'action:repair:order:approve', name: '审批', type: 'action' },
|
||||
{ code: 'action:repair:order:export', name: '导出', type: 'action' },
|
||||
{ code: 'action:repair:order:assign', name: '分配', type: 'action' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'feature:repair:batch', name: '批量操作', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:repair:batch:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:repair:batch:export', name: '导出', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'page:repair:detail', name: '工单详情', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:repair:delay-approve', name: '延期审批', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:repair:delay:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:repair:delay:approve', name: '审批', type: 'action' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'feature:repair:acceptance', name: '工单验收', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:repair:acceptance:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:repair:acceptance:approve', name: '审批', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'page:repair:type-manage', name: '报修类型管理', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:repair:type', name: '类型管理', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:repair:type:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:repair:type:create', name: '新增', type: 'action' },
|
||||
{ code: 'action:repair:type:update', name: '编辑', type: 'action' },
|
||||
{ code: 'action:repair:type:delete', name: '删除', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:inspection', name: '巡检管理', type: 'menu',
|
||||
children: [
|
||||
{
|
||||
code: 'page:inspection:plan', name: '巡检计划', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:inspection:plan-manage', name: '计划管理', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:inspection:plan:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:inspection:plan:create', name: '新增', type: 'action' },
|
||||
{ code: 'action:inspection:plan:update', name: '编辑', type: 'action' },
|
||||
{ code: 'action:inspection:plan:delete', name: '删除', type: 'action' },
|
||||
{ code: 'action:inspection:plan:export', name: '导出', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'page:inspection:task', name: '巡检任务', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:inspection:task-manage', name: '任务管理', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:inspection:task:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:inspection:task:execute', name: '执行', type: 'action' },
|
||||
{ code: 'action:inspection:task:export', name: '导出', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:contract', name: '合同管理', type: 'menu',
|
||||
children: [
|
||||
{
|
||||
code: 'page:contract:list', name: '合同列表', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:contract:manage', name: '合同管理', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:contract:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:contract:create', name: '新增', type: 'action' },
|
||||
{ code: 'action:contract:update', name: '编辑', type: 'action' },
|
||||
{ code: 'action:contract:delete', name: '删除', type: 'action' },
|
||||
{ code: 'action:contract:approve', name: '审批', type: 'action' },
|
||||
{ code: 'action:contract:export', name: '导出', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:cleaning', name: '保洁管理', type: 'menu',
|
||||
children: [
|
||||
{
|
||||
code: 'page:cleaning:task', name: '保洁任务', type: 'page',
|
||||
children: [
|
||||
{
|
||||
code: 'feature:cleaning:manage', name: '保洁管理', type: 'feature',
|
||||
children: [
|
||||
{ code: 'action:cleaning:view', name: '查看', type: 'action' },
|
||||
{ code: 'action:cleaning:execute', name: '执行', type: 'action' },
|
||||
{ code: 'action:cleaning:export', name: '导出', type: 'action' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const roleList: Role[] = [
|
||||
{ id: 'role-1', name: '医院查看模板', description: '医院账号默认查看权限', scope: 'hospital', isPreset: true, accountCount: 15, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-2', name: '医院管理模板', description: '医院账号管理权限', scope: 'hospital', isPreset: true, accountCount: 5, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-3', name: '医院审批模板', description: '医院账号审批权限', scope: 'hospital', isPreset: true, accountCount: 3, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-4', name: '物业管理员模板', description: '物业公司管理员默认权限', scope: 'property_admin', isPreset: true, accountCount: 20, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-5', name: '主管模板', description: '主管级权限', scope: 'property_staff', isPreset: true, accountCount: 8, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-6', name: '班组长模板', description: '班组长权限', scope: 'property_staff', isPreset: true, accountCount: 12, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-7', name: '维修员模板', description: '维修人员默认权限', scope: 'property_staff', isPreset: true, accountCount: 23, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-8', name: '巡检员模板', description: '巡检人员默认权限', scope: 'property_staff', isPreset: true, accountCount: 10, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-9', name: '保洁员模板', description: '保洁人员默认权限', scope: 'property_staff', isPreset: true, accountCount: 15, status: 'enabled', createdAt: '2025-01-15 10:00:00' },
|
||||
{ id: 'role-10', name: '巡检主管', description: '巡检主管自定义权限', scope: 'property_staff', isPreset: false, accountCount: 3, status: 'enabled', createdAt: '2025-06-01 14:00:00' },
|
||||
{ id: 'role-11', name: '高级维修员', description: '高级维修员扩展权限', scope: 'property_staff', isPreset: false, accountCount: 5, status: 'enabled', createdAt: '2025-07-20 09:00:00' },
|
||||
{ id: 'role-12', name: '保洁主管', description: '保洁主管自定义权限', scope: 'property_staff', isPreset: false, accountCount: 2, status: 'enabled', createdAt: '2025-08-10 11:00:00' },
|
||||
{ id: 'role-13', name: '合同管理员', description: '合同管理专用角色', scope: 'property_admin', isPreset: false, accountCount: 0, status: 'enabled', createdAt: '2025-09-05 16:00:00' },
|
||||
{ id: 'role-14', name: '已停用测试角色', description: '测试用停用角色', scope: 'hospital', isPreset: false, accountCount: 0, status: 'disabled', createdAt: '2025-10-01 08:00:00' },
|
||||
{ id: 'role-15', name: '只读审计角色', description: '仅查看审计日志', scope: 'hospital', isPreset: false, accountCount: 1, status: 'enabled', createdAt: '2025-11-15 13:00:00' }
|
||||
]
|
||||
|
||||
const rolePermissionsMap: Record<string, string[]> = {
|
||||
'role-1': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'menu:contract', 'page:contract:list', 'feature:contract:manage', 'action:contract:view'],
|
||||
'role-4': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'action:repair:order:create', 'action:repair:order:update', 'action:repair:order:assign', 'action:repair:order:export', 'page:repair:detail', 'feature:repair:delay-approve', 'action:repair:delay:view', 'feature:repair:acceptance', 'action:repair:acceptance:view', 'menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view'],
|
||||
'role-5': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'action:repair:order:approve', 'action:repair:order:export', 'menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view', 'action:inspection:plan:export'],
|
||||
'role-7': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'page:repair:detail', 'feature:repair:delay-approve', 'action:repair:delay:view'],
|
||||
'role-8': ['menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view', 'page:inspection:task', 'feature:inspection:task-manage', 'action:inspection:task:view', 'action:inspection:task:execute'],
|
||||
'role-10': ['menu:repair', 'page:repair:list', 'feature:repair:order-manage', 'action:repair:order:view', 'action:repair:order:approve', 'action:repair:order:export', 'menu:inspection', 'page:inspection:plan', 'feature:inspection:plan-manage', 'action:inspection:plan:view', 'action:inspection:plan:create', 'action:inspection:plan:update', 'action:inspection:plan:export', 'page:inspection:task', 'feature:inspection:task-manage', 'action:inspection:task:view', 'action:inspection:task:export']
|
||||
}
|
||||
|
||||
const permissionRegistryList: PermissionRegistryItem[] = [
|
||||
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_list', pageName: '工单列表', featureCode: 'order_manage', featureName: '工单管理', actions: ['view', 'create', 'update', 'delete', 'approve', 'export', 'assign'] },
|
||||
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_list', pageName: '工单列表', featureCode: 'batch_operate', featureName: '批量操作', actions: ['view', 'export'] },
|
||||
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_detail', pageName: '工单详情', featureCode: 'delay_approve', featureName: '延期审批', actions: ['view', 'approve'] },
|
||||
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_detail', pageName: '工单详情', featureCode: 'order_acceptance', featureName: '工单验收', actions: ['view', 'approve'] },
|
||||
{ moduleCode: 'repair', moduleName: '在线报修', pageCode: 'repair_type', pageName: '报修类型管理', featureCode: 'type_manage', featureName: '类型管理', actions: ['view', 'create', 'update', 'delete'] },
|
||||
{ moduleCode: 'inspection', moduleName: '巡检管理', pageCode: 'insp_plan', pageName: '巡检计划', featureCode: 'plan_manage', featureName: '计划管理', actions: ['view', 'create', 'update', 'delete', 'export'] },
|
||||
{ moduleCode: 'inspection', moduleName: '巡检管理', pageCode: 'insp_task', pageName: '巡检任务', featureCode: 'task_manage', featureName: '任务管理', actions: ['view', 'execute', 'export'] },
|
||||
{ moduleCode: 'contract', moduleName: '合同管理', pageCode: 'contract_list', pageName: '合同列表', featureCode: 'contract_manage', featureName: '合同管理', actions: ['view', 'create', 'update', 'delete', 'approve', 'export'] },
|
||||
{ moduleCode: 'cleaning', moduleName: '保洁管理', pageCode: 'cleaning_task', pageName: '保洁任务', featureCode: 'cleaning_manage', featureName: '保洁管理', actions: ['view', 'execute', 'export'] },
|
||||
{ moduleCode: 'bidding', moduleName: '分段招标', pageCode: 'bidding_list', pageName: '招标列表', featureCode: 'bidding_manage', featureName: '招标管理', actions: ['view', 'create', 'update', 'delete', 'approve', 'export'] },
|
||||
{ moduleCode: 'service', moduleName: '服务监督', pageCode: 'service_monitor', pageName: '服务监督', featureCode: 'monitor_manage', featureName: '监督管理', actions: ['view', 'create', 'update', 'export'] },
|
||||
{ moduleCode: 'evaluation', moduleName: '服务评价', pageCode: 'eval_list', pageName: '评价列表', featureCode: 'eval_manage', featureName: '评价管理', actions: ['view', 'export'] }
|
||||
]
|
||||
|
||||
const permissionAuditLogList: PermissionAuditLog[] = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `paudit-${i + 1}`,
|
||||
operationTime: `2026-04-${String(17 - Math.floor(i / 5)).padStart(2, '0')} ${String(9 + (i % 8)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}:00`,
|
||||
operator: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'super_admin' : 'system',
|
||||
operationType: (['role_create', 'permission_modify', 'role_assign', 'role_remove'] as const)[i % 4],
|
||||
targetRole: roleList[i % roleList.length].name,
|
||||
changeSummary: i % 4 === 0 ? '初始权限' : `+${2 + (i % 3)}项 -${i % 2}项`,
|
||||
addCount: i % 4 === 0 ? 0 : 2 + (i % 3),
|
||||
removeCount: i % 4 === 0 ? 0 : i % 2
|
||||
}))
|
||||
|
||||
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
function filterList<T>(list: T[], query: Record<string, any>, page: number, pageSize: number): PageResponse<T> {
|
||||
let filtered = [...list]
|
||||
if (query.name) filtered = filtered.filter((item: any) => item.name?.includes(query.name))
|
||||
if (query.scope && query.scope !== '') filtered = filtered.filter((item: any) => item.scope === query.scope)
|
||||
if (query.status && query.status !== '') filtered = filtered.filter((item: any) => item.status === query.status)
|
||||
if (query.moduleName) filtered = filtered.filter((item: any) => item.moduleName?.includes(query.moduleName))
|
||||
if (query.pageName) filtered = filtered.filter((item: any) => item.pageName?.includes(query.pageName))
|
||||
if (query.operator) filtered = filtered.filter((item: any) => item.operator?.includes(query.operator))
|
||||
if (query.operationType && query.operationType !== '') filtered = filtered.filter((item: any) => item.operationType === query.operationType)
|
||||
if (query.role) filtered = filtered.filter((item: any) => item.targetRole?.includes(query.role))
|
||||
const total = filtered.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
return { list: filtered.slice((page - 1) * pageSize, page * pageSize), pagination: { page, pageSize, total, totalPages } }
|
||||
}
|
||||
|
||||
export const mockPermissionApi = {
|
||||
async getRoleList(query: RoleQuery): Promise<PageResponse<Role>> {
|
||||
await delay(300)
|
||||
return filterList(roleList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async getRoleDetail(id: string): Promise<Role> {
|
||||
await delay(200)
|
||||
const role = roleList.find(r => r.id === id)
|
||||
if (!role) throw new Error('角色不存在')
|
||||
return { ...role }
|
||||
},
|
||||
|
||||
async createRole(data: RoleFormData): Promise<{ id: string }> {
|
||||
await delay(500)
|
||||
const id = `role-${Date.now()}`
|
||||
roleList.unshift({
|
||||
id, name: data.name, description: data.description, scope: data.scope,
|
||||
isPreset: false, accountCount: 0, status: 'enabled', createdAt: new Date().toISOString()
|
||||
})
|
||||
rolePermissionsMap[id] = data.permissions
|
||||
return { id }
|
||||
},
|
||||
|
||||
async updateRole(id: string, data: RoleFormData): Promise<void> {
|
||||
await delay(500)
|
||||
const idx = roleList.findIndex(r => r.id === id)
|
||||
if (idx === -1) throw new Error('角色不存在')
|
||||
roleList[idx] = { ...roleList[idx], name: data.name, description: data.description, scope: data.scope }
|
||||
rolePermissionsMap[id] = data.permissions
|
||||
},
|
||||
|
||||
async disableRole(id: string): Promise<void> {
|
||||
await delay(400)
|
||||
const role = roleList.find(r => r.id === id)
|
||||
if (!role) throw new Error('角色不存在')
|
||||
if (role.accountCount > 0) throw new Error('该角色下存在关联账号,无法停用')
|
||||
role.status = 'disabled'
|
||||
},
|
||||
|
||||
async deleteRole(id: string): Promise<void> {
|
||||
await delay(400)
|
||||
const idx = roleList.findIndex(r => r.id === id)
|
||||
if (idx === -1) throw new Error('角色不存在')
|
||||
if (roleList[idx].accountCount > 0) throw new Error('该角色下存在关联账号,无法删除')
|
||||
roleList.splice(idx, 1)
|
||||
},
|
||||
|
||||
async getPermissionTree(): Promise<PermissionNode[]> {
|
||||
await delay(300)
|
||||
return JSON.parse(JSON.stringify(permissionTree))
|
||||
},
|
||||
|
||||
async getRolePermissions(id: string): Promise<PermissionNode[]> {
|
||||
await delay(300)
|
||||
const codes = rolePermissionsMap[id] || []
|
||||
// Return full tree with checked state based on codes
|
||||
function markChecked(nodes: PermissionNode[]): PermissionNode[] {
|
||||
return nodes.map(n => ({
|
||||
...n,
|
||||
children: n.children ? markChecked(n.children) : undefined
|
||||
}))
|
||||
}
|
||||
return markChecked(permissionTree)
|
||||
},
|
||||
|
||||
async getPermissionRegistryList(query: PermissionRegistryQuery): Promise<PageResponse<PermissionRegistryItem>> {
|
||||
await delay(300)
|
||||
return filterList(permissionRegistryList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async refreshPermissionRegistry(): Promise<void> {
|
||||
await delay(1000)
|
||||
},
|
||||
|
||||
async getPermissionAuditLogList(query: PermissionAuditLogQuery): Promise<PageResponse<PermissionAuditLog>> {
|
||||
await delay(300)
|
||||
return filterList(permissionAuditLogList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async getPermissionAuditDetail(id: string): Promise<PermissionAuditDetail> {
|
||||
await delay(200)
|
||||
const log = permissionAuditLogList.find(l => l.id === id)
|
||||
if (!log) throw new Error('日志不存在')
|
||||
return {
|
||||
id: log.id,
|
||||
operationTime: log.operationTime,
|
||||
operator: log.operator,
|
||||
operationType: log.operationType,
|
||||
targetRole: log.targetRole,
|
||||
operationIp: '192.168.1.100',
|
||||
addedPermissions: log.addCount > 0 ? [
|
||||
'在线报修 → 工单详情 → 延期审批 → 审批',
|
||||
'在线报修 → 工单列表 → 工单管理 → 导出',
|
||||
...Array.from({ length: Math.max(0, log.addCount - 2) }, (_, i) => `权限项${i + 3}`)
|
||||
] : [],
|
||||
removedPermissions: log.removeCount > 0 ? [
|
||||
'巡检管理 → 巡检计划 → 计划管理 → 删除',
|
||||
...Array.from({ length: Math.max(0, log.removeCount - 1) }, (_, i) => `移除权限${i + 2}`)
|
||||
] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
import type { SystemVersion, SystemVersionFormData, CacheStatus, CacheModule, AccountAuditLog, AccountAuditLogQuery, AccountAuditDetail } from '../types/system'
|
||||
import type { PageResponse } from '../types/common'
|
||||
|
||||
// ===== Mock数据 =====
|
||||
|
||||
const versionList: SystemVersion[] = [
|
||||
{ id: 'v-1', version: '1.2.0', platform: 'web', minCompatibleVersion: '1.0.0', forceUpdate: false, releaseDate: '2026-04-01', description: '新增权限管理模块、优化系统性能、修复已知问题' },
|
||||
{ id: 'v-2', version: '1.1.0', platform: 'miniapp', minCompatibleVersion: '1.0.0', forceUpdate: false, releaseDate: '2026-03-15', description: '新增巡检打卡功能、优化报修流程、修复蓝牙连接问题' },
|
||||
{ id: 'v-3', version: '1.0.0', platform: 'web', minCompatibleVersion: '1.0.0', forceUpdate: false, releaseDate: '2026-01-01', description: '系统初始版本,包含基础功能模块' },
|
||||
{ id: 'v-4', version: '1.0.0', platform: 'miniapp', minCompatibleVersion: '1.0.0', forceUpdate: true, releaseDate: '2026-01-01', description: '小程序初始版本' },
|
||||
{ id: 'v-5', version: '0.9.0', platform: 'miniapp', minCompatibleVersion: '0.9.0', forceUpdate: true, releaseDate: '2025-12-01', description: '内测版本' }
|
||||
]
|
||||
|
||||
const cacheStatus: CacheStatus = {
|
||||
redisStatus: 'connected',
|
||||
memoryUsed: 128,
|
||||
memoryTotal: 512,
|
||||
connections: 24
|
||||
}
|
||||
|
||||
const cacheModules: CacheModule[] = [
|
||||
{ name: '权限缓存', keyPrefix: 'perm:*', count: 1234, lastUpdateTime: '2026-04-17 10:30:25' },
|
||||
{ name: '字典缓存', keyPrefix: 'dict:*', count: 56, lastUpdateTime: '2026-04-17 09:00:00' },
|
||||
{ name: '菜单缓存', keyPrefix: 'menu:*', count: 120, lastUpdateTime: '2026-04-17 09:00:00' },
|
||||
{ name: '业务缓存', keyPrefix: 'biz:*', count: 0, lastUpdateTime: '—' }
|
||||
]
|
||||
|
||||
const accountAuditLogList: AccountAuditLog[] = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `alog-${i + 1}`,
|
||||
operationTime: `2026-04-${String(17 - Math.floor(i / 4)).padStart(2, '0')} ${String(8 + (i % 9)).padStart(2, '0')}:${String((i * 23) % 60).padStart(2, '0')}:00`,
|
||||
operator: i % 2 === 0 ? 'admin' : 'super_admin',
|
||||
operationType: (['create', 'edit', 'enable', 'disable', 'renew', 'reset_password'] as const)[i % 6],
|
||||
targetAccount: i % 2 === 0 ? `hospital${String(i + 1).padStart(2, '0')}` : `propadmin${String(i + 1).padStart(2, '0')}`,
|
||||
accountType: i % 2 === 0 ? 'hospital' : 'property_admin',
|
||||
bindUnit: i % 2 === 0 ? '北京协和医院' : '万科物业'
|
||||
}))
|
||||
|
||||
function delay(ms: number) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
function filterList<T>(list: T[], query: Record<string, any>, page: number, pageSize: number): PageResponse<T> {
|
||||
let filtered = [...list]
|
||||
if (query.operator) filtered = filtered.filter((item: any) => item.operator?.includes(query.operator))
|
||||
if (query.operationType && query.operationType !== '') filtered = filtered.filter((item: any) => item.operationType === query.operationType)
|
||||
if (query.accountType && query.accountType !== '') filtered = filtered.filter((item: any) => item.accountType === query.accountType)
|
||||
const total = filtered.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
return { list: filtered.slice((page - 1) * pageSize, page * pageSize), pagination: { page, pageSize, total, totalPages } }
|
||||
}
|
||||
|
||||
export const mockSystemApi = {
|
||||
async getSystemVersionList(): Promise<SystemVersion[]> {
|
||||
await delay(200)
|
||||
return [...versionList]
|
||||
},
|
||||
|
||||
async createSystemVersion(data: SystemVersionFormData): Promise<{ id: string }> {
|
||||
await delay(500)
|
||||
const id = `v-${Date.now()}`
|
||||
versionList.unshift({ id, ...data, releaseDate: new Date().toISOString().split('T')[0] })
|
||||
return { id }
|
||||
},
|
||||
|
||||
async updateSystemVersion(id: string, data: SystemVersionFormData): Promise<void> {
|
||||
await delay(500)
|
||||
const idx = versionList.findIndex(v => v.id === id)
|
||||
if (idx === -1) throw new Error('版本不存在')
|
||||
versionList[idx] = { ...versionList[idx], ...data }
|
||||
},
|
||||
|
||||
async getLatestVersions(): Promise<{ web: SystemVersion | null; miniapp: SystemVersion | null }> {
|
||||
await delay(200)
|
||||
const web = versionList.find(v => v.platform === 'web') || null
|
||||
const miniapp = versionList.find(v => v.platform === 'miniapp') || null
|
||||
return { web, miniapp }
|
||||
},
|
||||
|
||||
async getCacheStatus(): Promise<CacheStatus> {
|
||||
await delay(200)
|
||||
return { ...cacheStatus }
|
||||
},
|
||||
|
||||
async getCacheModules(): Promise<CacheModule[]> {
|
||||
await delay(200)
|
||||
return [...cacheModules]
|
||||
},
|
||||
|
||||
async clearCacheModule(module: string): Promise<void> {
|
||||
await delay(800)
|
||||
const mod = cacheModules.find(m => m.keyPrefix.startsWith(module.split(':')[0]))
|
||||
if (mod) { mod.count = 0; mod.lastUpdateTime = new Date().toISOString() }
|
||||
},
|
||||
|
||||
async clearAllCache(): Promise<void> {
|
||||
await delay(1500)
|
||||
cacheModules.forEach(m => { m.count = 0; m.lastUpdateTime = new Date().toISOString() })
|
||||
}
|
||||
}
|
||||
|
||||
export const mockAuditLogApi = {
|
||||
async getAccountAuditLogList(query: AccountAuditLogQuery): Promise<PageResponse<AccountAuditLog>> {
|
||||
await delay(300)
|
||||
return filterList(accountAuditLogList, query, query.page, query.pageSize)
|
||||
},
|
||||
|
||||
async getAccountAuditDetail(id: string): Promise<AccountAuditDetail> {
|
||||
await delay(200)
|
||||
const log = accountAuditLogList.find(l => l.id === id)
|
||||
if (!log) throw new Error('日志不存在')
|
||||
return {
|
||||
id: log.id,
|
||||
operationTime: log.operationTime,
|
||||
operator: log.operator,
|
||||
operationIp: '192.168.1.100',
|
||||
operationType: log.operationType,
|
||||
targetAccount: log.targetAccount,
|
||||
accountType: log.accountType,
|
||||
bindUnit: log.bindUnit,
|
||||
beforeData: log.operationType === 'create' ? null : {
|
||||
username: log.targetAccount,
|
||||
status: log.operationType === 'disable' ? 'normal' : undefined,
|
||||
expireDate: log.operationType === 'renew' ? '2026-03-01' : undefined
|
||||
},
|
||||
afterData: {
|
||||
username: log.targetAccount,
|
||||
status: log.operationType === 'disable' ? 'stopped' : log.operationType === 'enable' ? 'normal' : undefined,
|
||||
expireDate: log.operationType === 'renew' ? '2027-04-16' : undefined,
|
||||
roles: log.operationType === 'create' ? ['医院查看模板'] : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
import type { PageQuery, PageResponse, CommonStatus, AccountStatus } from './common'
|
||||
|
||||
// ===== 医院信息 =====
|
||||
export interface Hospital {
|
||||
id: string
|
||||
name: string
|
||||
creditCode: string
|
||||
address: string
|
||||
contactPerson: string
|
||||
contactPhone: string
|
||||
status: CommonStatus
|
||||
campusCount: number
|
||||
campuses: HospitalCampus[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface HospitalCampus {
|
||||
id?: string
|
||||
name: string
|
||||
address: string
|
||||
contactPerson: string
|
||||
}
|
||||
|
||||
export interface HospitalQuery extends PageQuery {
|
||||
name?: string
|
||||
status?: CommonStatus | ''
|
||||
contactPerson?: string
|
||||
}
|
||||
|
||||
export interface HospitalFormData {
|
||||
name: string
|
||||
creditCode: string
|
||||
address: string
|
||||
contactPerson: string
|
||||
contactPhone: string
|
||||
status: CommonStatus
|
||||
campuses: HospitalCampus[]
|
||||
}
|
||||
|
||||
// ===== 物业公司信息 =====
|
||||
export interface PropertyCompany {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
contactPerson: string
|
||||
contactPhone: string
|
||||
status: CommonStatus
|
||||
serviceHospitals: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface PropertyCompanyQuery extends PageQuery {
|
||||
name?: string
|
||||
status?: CommonStatus | ''
|
||||
contactPerson?: string
|
||||
}
|
||||
|
||||
export interface PropertyCompanyFormData {
|
||||
name: string
|
||||
address: string
|
||||
contactPerson: string
|
||||
contactPhone: string
|
||||
}
|
||||
|
||||
// ===== 医院账号 =====
|
||||
export interface HospitalAccount {
|
||||
id: string
|
||||
username: string
|
||||
hospitalId: string
|
||||
hospitalName: string
|
||||
roles: string[]
|
||||
roleNames: string[]
|
||||
expireDate: string
|
||||
status: AccountStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface HospitalAccountQuery extends PageQuery {
|
||||
username?: string
|
||||
hospitalId?: string
|
||||
status?: AccountStatus | ''
|
||||
expireFilter?: string
|
||||
}
|
||||
|
||||
export interface HospitalAccountFormData {
|
||||
username: string
|
||||
password: string
|
||||
hospitalId: string
|
||||
expireDate: string
|
||||
roleIds: string[]
|
||||
}
|
||||
|
||||
// ===== 物业管理员账号 =====
|
||||
export interface PropertyAccount {
|
||||
id: string
|
||||
username: string
|
||||
propertyCompanyId: string
|
||||
propertyName: string
|
||||
hospitalId: string
|
||||
hospitalName: string
|
||||
roles: string[]
|
||||
roleNames: string[]
|
||||
expireDate: string
|
||||
status: AccountStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PropertyAccountQuery extends PageQuery {
|
||||
username?: string
|
||||
propertyCompanyId?: string
|
||||
hospitalId?: string
|
||||
status?: AccountStatus | ''
|
||||
}
|
||||
|
||||
export interface PropertyAccountFormData {
|
||||
username: string
|
||||
password: string
|
||||
propertyCompanyId: string
|
||||
hospitalId: string
|
||||
expireDate: string
|
||||
roleIds: string[]
|
||||
}
|
||||
|
||||
// ===== 到期账号 =====
|
||||
export interface ExpiringAccount {
|
||||
id: string
|
||||
username: string
|
||||
accountType: 'hospital' | 'property_admin'
|
||||
bindUnit: string
|
||||
expireDate: string
|
||||
remainDays: number
|
||||
status: AccountStatus
|
||||
}
|
||||
|
||||
export interface ExpiringAccountQuery extends PageQuery {
|
||||
accountType?: 'hospital' | 'property_admin' | ''
|
||||
expireStatus?: string
|
||||
}
|
||||
|
||||
export interface ExpiringStats {
|
||||
expired: number
|
||||
expiringIn7Days: number
|
||||
expiringIn30Days: number
|
||||
}
|
||||
|
||||
// ===== 到期提醒配置 =====
|
||||
export interface ExpiryReminderConfig {
|
||||
reminderDays: number[]
|
||||
loginPopup: boolean
|
||||
popupCloseAction: 'normal' | 'restrict'
|
||||
expiredAction: 'block' | 'remind'
|
||||
}
|
||||
|
||||
// ===== 账号API接口 =====
|
||||
export interface IAccountApi {
|
||||
// 医院
|
||||
getHospitalList(query: HospitalQuery): Promise<PageResponse<Hospital>>
|
||||
getHospitalDetail(id: string): Promise<Hospital>
|
||||
createHospital(data: HospitalFormData): Promise<{ id: string }>
|
||||
updateHospital(id: string, data: HospitalFormData): Promise<void>
|
||||
toggleHospitalStatus(id: string): Promise<void>
|
||||
|
||||
// 物业公司
|
||||
getPropertyCompanyList(query: PropertyCompanyQuery): Promise<PageResponse<PropertyCompany>>
|
||||
getPropertyCompanyDetail(id: string): Promise<PropertyCompany>
|
||||
createPropertyCompany(data: PropertyCompanyFormData): Promise<{ id: string }>
|
||||
updatePropertyCompany(id: string, data: PropertyCompanyFormData): Promise<void>
|
||||
togglePropertyCompanyStatus(id: string): Promise<void>
|
||||
|
||||
// 医院账号
|
||||
getHospitalAccountList(query: HospitalAccountQuery): Promise<PageResponse<HospitalAccount>>
|
||||
createHospitalAccount(data: HospitalAccountFormData): Promise<{ id: string }>
|
||||
toggleAccountStatus(id: string): Promise<void>
|
||||
renewAccount(id: string, expireDate: string): Promise<void>
|
||||
resetPassword(id: string): Promise<{ newPassword: string }>
|
||||
|
||||
// 物业管理员账号
|
||||
getPropertyAccountList(query: PropertyAccountQuery): Promise<PageResponse<PropertyAccount>>
|
||||
createPropertyAccount(data: PropertyAccountFormData): Promise<{ id: string }>
|
||||
|
||||
// 到期账号
|
||||
getExpiringAccountList(query: ExpiringAccountQuery): Promise<PageResponse<ExpiringAccount>>
|
||||
getExpiringStats(): Promise<ExpiringStats>
|
||||
|
||||
// 到期提醒配置
|
||||
getExpiryReminderConfig(): Promise<ExpiryReminderConfig>
|
||||
saveExpiryReminderConfig(data: ExpiryReminderConfig): Promise<void>
|
||||
|
||||
// 下拉数据
|
||||
getHospitalOptions(): Promise<{ id: string; name: string }[]>
|
||||
getPropertyCompanyOptions(): Promise<{ id: string; name: string }[]>
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
// 超级管理员 - 通用类型定义
|
||||
|
||||
/** 分页请求参数 */
|
||||
export interface PageQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
_mockError?: 'empty' | '403' | 'timeout' | '500'
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface PageResponse<T> {
|
||||
list: T[]
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
/** API错误 */
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
constructor(code: number, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** 通用状态 */
|
||||
export type CommonStatus = 'enabled' | 'disabled'
|
||||
|
||||
/** 账号状态 */
|
||||
export type AccountStatus = 'normal' | 'expiring' | 'expired' | 'stopped'
|
||||
|
||||
/** 适用范围 */
|
||||
export type RoleScope = 'hospital' | 'property_admin' | 'property_staff'
|
||||
@ -0,0 +1,108 @@
|
||||
import type { PageQuery, PageResponse, CommonStatus, RoleScope } from './common'
|
||||
|
||||
// ===== 角色 =====
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
scope: RoleScope
|
||||
isPreset: boolean
|
||||
accountCount: number
|
||||
status: CommonStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface RoleQuery extends PageQuery {
|
||||
name?: string
|
||||
scope?: RoleScope | ''
|
||||
status?: CommonStatus | ''
|
||||
}
|
||||
|
||||
export interface RoleFormData {
|
||||
name: string
|
||||
description: string
|
||||
scope: RoleScope
|
||||
presetTemplate?: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
// ===== 权限树 =====
|
||||
export interface PermissionNode {
|
||||
code: string
|
||||
name: string
|
||||
type: 'menu' | 'page' | 'feature' | 'action'
|
||||
children?: PermissionNode[]
|
||||
}
|
||||
|
||||
// ===== 权限预览 =====
|
||||
export interface RolePermission {
|
||||
menu: string
|
||||
page: string
|
||||
feature: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
// ===== 权限配置注册 =====
|
||||
export interface PermissionRegistryItem {
|
||||
moduleCode: string
|
||||
moduleName: string
|
||||
pageCode: string
|
||||
pageName: string
|
||||
featureCode: string
|
||||
featureName: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface PermissionRegistryQuery extends PageQuery {
|
||||
moduleName?: string
|
||||
pageName?: string
|
||||
}
|
||||
|
||||
// ===== 权限审计日志 =====
|
||||
export interface PermissionAuditLog {
|
||||
id: string
|
||||
operationTime: string
|
||||
operator: string
|
||||
operationType: 'role_create' | 'permission_modify' | 'role_assign' | 'role_remove'
|
||||
targetRole: string
|
||||
changeSummary: string
|
||||
addCount: number
|
||||
removeCount: number
|
||||
}
|
||||
|
||||
export interface PermissionAuditLogQuery extends PageQuery {
|
||||
operator?: string
|
||||
operationType?: string
|
||||
role?: string
|
||||
dateRange?: [string, string]
|
||||
}
|
||||
|
||||
export interface PermissionAuditDetail {
|
||||
id: string
|
||||
operationTime: string
|
||||
operator: string
|
||||
operationType: string
|
||||
targetRole: string
|
||||
operationIp: string
|
||||
addedPermissions: string[]
|
||||
removedPermissions: string[]
|
||||
}
|
||||
|
||||
// ===== 权限API接口 =====
|
||||
export interface IPermissionApi {
|
||||
getRoleList(query: RoleQuery): Promise<PageResponse<Role>>
|
||||
getRoleDetail(id: string): Promise<Role>
|
||||
createRole(data: RoleFormData): Promise<{ id: string }>
|
||||
updateRole(id: string, data: RoleFormData): Promise<void>
|
||||
disableRole(id: string): Promise<void>
|
||||
deleteRole(id: string): Promise<void>
|
||||
|
||||
getPermissionTree(): Promise<PermissionNode[]>
|
||||
getRolePermissions(id: string): Promise<PermissionNode[]>
|
||||
|
||||
getPermissionRegistryList(query: PermissionRegistryQuery): Promise<PageResponse<PermissionRegistryItem>>
|
||||
refreshPermissionRegistry(): Promise<void>
|
||||
|
||||
getPermissionAuditLogList(query: PermissionAuditLogQuery): Promise<PageResponse<PermissionAuditLog>>
|
||||
getPermissionAuditDetail(id: string): Promise<PermissionAuditDetail>
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import type { PageQuery, PageResponse } from './common'
|
||||
|
||||
// ===== 系统版本 =====
|
||||
export interface SystemVersion {
|
||||
id: string
|
||||
version: string
|
||||
platform: 'web' | 'miniapp'
|
||||
minCompatibleVersion: string
|
||||
forceUpdate: boolean
|
||||
releaseDate: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface SystemVersionFormData {
|
||||
version: string
|
||||
platform: 'web' | 'miniapp'
|
||||
minCompatibleVersion: string
|
||||
forceUpdate: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
// ===== 缓存管理 =====
|
||||
export interface CacheStatus {
|
||||
redisStatus: 'connected' | 'disconnected'
|
||||
memoryUsed: number
|
||||
memoryTotal: number
|
||||
connections: number
|
||||
}
|
||||
|
||||
export interface CacheModule {
|
||||
name: string
|
||||
keyPrefix: string
|
||||
count: number
|
||||
lastUpdateTime: string
|
||||
}
|
||||
|
||||
// ===== 账号操作日志 =====
|
||||
export interface AccountAuditLog {
|
||||
id: string
|
||||
operationTime: string
|
||||
operator: string
|
||||
operationType: 'create' | 'edit' | 'enable' | 'disable' | 'renew' | 'reset_password'
|
||||
targetAccount: string
|
||||
accountType: 'hospital' | 'property_admin'
|
||||
bindUnit: string
|
||||
}
|
||||
|
||||
export interface AccountAuditLogQuery extends PageQuery {
|
||||
operator?: string
|
||||
operationType?: string
|
||||
accountType?: 'hospital' | 'property_admin' | ''
|
||||
dateRange?: [string, string]
|
||||
}
|
||||
|
||||
export interface AccountAuditDetail {
|
||||
id: string
|
||||
operationTime: string
|
||||
operator: string
|
||||
operationIp: string
|
||||
operationType: string
|
||||
targetAccount: string
|
||||
accountType: string
|
||||
bindUnit: string
|
||||
beforeData: Record<string, any> | null
|
||||
afterData: Record<string, any> | null
|
||||
}
|
||||
|
||||
// ===== 系统API接口 =====
|
||||
export interface ISystemApi {
|
||||
getSystemVersionList(): Promise<SystemVersion[]>
|
||||
createSystemVersion(data: SystemVersionFormData): Promise<{ id: string }>
|
||||
updateSystemVersion(id: string, data: SystemVersionFormData): Promise<void>
|
||||
getLatestVersions(): Promise<{ web: SystemVersion | null; miniapp: SystemVersion | null }>
|
||||
|
||||
getCacheStatus(): Promise<CacheStatus>
|
||||
getCacheModules(): Promise<CacheModule[]>
|
||||
clearCacheModule(module: string): Promise<void>
|
||||
clearAllCache(): Promise<void>
|
||||
}
|
||||
|
||||
export interface IAuditLogApi {
|
||||
getAccountAuditLogList(query: AccountAuditLogQuery): Promise<PageResponse<AccountAuditLog>>
|
||||
getAccountAuditDetail(id: string): Promise<AccountAuditDetail>
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@ -0,0 +1,21 @@
|
||||
<!-- @shared 操作按钮栏 -->
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-bar">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.action-bar {
|
||||
background: #fff;
|
||||
padding: $--spacing-item $--spacing-section;
|
||||
border-radius: 4px;
|
||||
margin-bottom: $--spacing-item;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,30 @@
|
||||
<!-- @shared 面包屑导航 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched.filter(item => item.meta?.title)
|
||||
return matched.map(item => ({
|
||||
title: item.meta.title as string,
|
||||
path: item.path,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
{{ item.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.breadcrumb {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,76 @@
|
||||
<!-- @shared 数据表格 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { DocumentDelete } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
loading?: boolean
|
||||
selectable?: boolean
|
||||
rowKey?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: any[]]
|
||||
'sort-change': [value: { prop: string; order: string }]
|
||||
}>()
|
||||
|
||||
const selectedRows = ref<any[]>([])
|
||||
|
||||
watch(selectedRows, (val) => {
|
||||
emit('update:selected', val)
|
||||
})
|
||||
|
||||
function handleSelectionChange(val: any[]) {
|
||||
selectedRows.value = val
|
||||
}
|
||||
|
||||
function handleSortChange({ prop, order }: { prop: string; order: string | null }) {
|
||||
emit('sort-change', { prop, order: order || '' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-table-wrapper">
|
||||
<el-table
|
||||
:data="data"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
size="default"
|
||||
:row-key="rowKey || 'id'"
|
||||
@selection-change="handleSelectionChange"
|
||||
@sort-change="handleSortChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column v-if="selectable" type="selection" width="55" />
|
||||
<slot />
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<el-icon :size="48" color="#c0c4cc"><DocumentDelete /></el-icon>
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.data-table-wrapper {
|
||||
background: #fff;
|
||||
padding: $--spacing-section;
|
||||
border-radius: 4px;
|
||||
margin-bottom: $--spacing-item;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,30 @@
|
||||
<!-- @shared 详情侧滑抽屉 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
title: string
|
||||
width?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="visible"
|
||||
:title="title"
|
||||
:size="width || '500px'"
|
||||
@close="handleClose"
|
||||
>
|
||||
<slot />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@ -0,0 +1,57 @@
|
||||
<!-- @shared 表单弹窗 -->
|
||||
<script setup lang="ts">
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
title: string
|
||||
loading?: boolean
|
||||
dirty?: boolean
|
||||
width?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
async function handleClose() {
|
||||
if (props.dirty) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前修改尚未保存,确定关闭吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('submit')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="title"
|
||||
:width="width || '500px'"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<slot />
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@ -0,0 +1,29 @@
|
||||
<!-- @shared 状态标签 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
status: string
|
||||
statusMap?: Record<string, { label: string; type: 'success' | 'danger' | 'warning' | 'info' | 'primary' }>
|
||||
}>()
|
||||
|
||||
const defaultMap: Record<string, { label: string; type: 'success' | 'danger' | 'warning' | 'info' | 'primary' }> = {
|
||||
enabled: { label: '启用', type: 'success' },
|
||||
disabled: { label: '停用', type: 'danger' },
|
||||
normal: { label: '正常', type: 'success' },
|
||||
expiring: { label: '即将到期', type: 'warning' },
|
||||
expired: { label: '已过期', type: 'danger' },
|
||||
stopped: { label: '已停用', type: 'info' },
|
||||
active: { label: '已连接', type: 'success' },
|
||||
disconnected: { label: '未连接', type: 'danger' },
|
||||
}
|
||||
|
||||
const current = computed(() => {
|
||||
const map = props.statusMap || defaultMap
|
||||
return map[props.status] || { label: props.status, type: 'info' as const }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag :type="current.type" size="default">{{ current.label }}</el-tag>
|
||||
</template>
|
||||
@ -0,0 +1,50 @@
|
||||
// @shared 共享组件注册表
|
||||
// 修改任何共享组件前,必须查阅此注册表确认影响范围
|
||||
// 修改后必须执行100%覆盖测试,并向用户发出变更通知
|
||||
|
||||
export const SharedComponentRegistry = {
|
||||
Breadcrumb: {
|
||||
path: 'components/shared/Breadcrumb/index.vue',
|
||||
description: '面包屑导航',
|
||||
referencedBy: ['所有页面'] as string[],
|
||||
},
|
||||
QueryPanel: {
|
||||
path: 'components/shared/QueryPanel/index.vue',
|
||||
description: '查询条件面板(折叠/展开)',
|
||||
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList', 'account:HospitalAccountList', 'account:PropertyAccountList', 'account:ExpiringAccountList', 'permission:RoleList', 'permission:PermissionRegistry', 'permission:PermissionAuditLog', 'audit-log:PermissionLog', 'audit-log:AccountLog'] as string[],
|
||||
},
|
||||
ActionBar: {
|
||||
path: 'components/shared/ActionBar/index.vue',
|
||||
description: '操作按钮栏(权限控制+批量操作)',
|
||||
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList', 'account:HospitalAccountList', 'account:PropertyAccountList', 'permission:RoleList', 'system:VersionList', 'system:CacheManagement'] as string[],
|
||||
},
|
||||
DataTable: {
|
||||
path: 'components/shared/DataTable/index.vue',
|
||||
description: '数据表格(排序/选择/行操作)',
|
||||
referencedBy: ['所有列表页'] as string[],
|
||||
},
|
||||
Pagination: {
|
||||
path: 'components/shared/Pagination/index.vue',
|
||||
description: '分页组件',
|
||||
referencedBy: ['所有列表页'] as string[],
|
||||
},
|
||||
FormDialog: {
|
||||
path: 'components/shared/FormDialog/index.vue',
|
||||
description: '表单弹窗(新增/编辑)',
|
||||
referencedBy: ['account:HospitalAccountList', 'account:PropertyAccountList', 'account:ExpiringAccountList', 'permission:RoleList', 'system:VersionList'] as string[],
|
||||
},
|
||||
DetailDrawer: {
|
||||
path: 'components/shared/DetailDrawer/index.vue',
|
||||
description: '详情侧滑抽屉',
|
||||
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList'] as string[],
|
||||
},
|
||||
StatusTag: {
|
||||
path: 'components/shared/StatusTag/index.vue',
|
||||
description: '状态标签(彩色区分)',
|
||||
referencedBy: ['account:HospitalList', 'account:PropertyCompanyList', 'account:HospitalAccountList', 'account:PropertyAccountList', 'account:ExpiringAccountList', 'permission:RoleList', 'audit-log:PermissionLog', 'audit-log:AccountLog'] as string[],
|
||||
},
|
||||
} as const
|
||||
|
||||
export function getComponentReferences(componentName: string): string[] {
|
||||
return SharedComponentRegistry[componentName]?.referencedBy ?? []
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_MODE: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_API_TIMEOUT_GET: string
|
||||
readonly VITE_API_TIMEOUT_POST: string
|
||||
readonly VITE_API_TIMEOUT_UPLOAD: string
|
||||
readonly VITE_API_TIMEOUT_STATS: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/global/useUserStore'
|
||||
import { useAppStore } from '@/stores/global/useAppStore'
|
||||
import Sidebar from './sidebar/Sidebar.vue'
|
||||
import HeaderBar from './header/HeaderBar.vue'
|
||||
import TagsView from './tags/TagsView.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => userStore.sidebarCollapsed)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside :width="sidebarCollapsed ? '64px' : '220px'" class="sidebar-container">
|
||||
<Sidebar />
|
||||
</el-aside>
|
||||
<el-container class="main-container">
|
||||
<el-header class="header-container" height="56px">
|
||||
<HeaderBar />
|
||||
</el-header>
|
||||
<TagsView />
|
||||
<el-main class="content-container">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="appStore.cachedViews">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
background: #304156;
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
background: #f5f7fa;
|
||||
padding: $--spacing-page;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/global/useUserStore'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
function toggleSidebar() {
|
||||
userStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="header-bar">
|
||||
<div class="left">
|
||||
<el-icon class="collapse-btn" @click="toggleSidebar">
|
||||
<Fold v-if="!userStore.sidebarCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
<span class="page-title">医院物业SaaS管理后台</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-info">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
{{ userStore.userInfo.realName }}
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-bar {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/global/useUserStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => userStore.sidebarCollapsed)
|
||||
|
||||
const menuList = [
|
||||
{
|
||||
title: '账号管理',
|
||||
icon: 'User',
|
||||
path: '/account',
|
||||
children: [
|
||||
{ title: '医院信息管理', path: '/account/hospitals' },
|
||||
{ title: '物业公司信息管理', path: '/account/property-companies' },
|
||||
{ title: '医院账号管理', path: '/account/hospital-accounts' },
|
||||
{ title: '物业管理员账号管理', path: '/account/property-accounts' },
|
||||
{ title: '到期账号管理', path: '/account/expiring' },
|
||||
{ title: '到期提醒规则配置', path: '/account/expiry-settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '权限管理',
|
||||
icon: 'Lock',
|
||||
path: '/permission',
|
||||
children: [
|
||||
{ title: '角色管理', path: '/permission/roles' },
|
||||
{ title: '权限配置注册', path: '/permission/registry' },
|
||||
{ title: '权限审计日志', path: '/permission/audit-log' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统配置',
|
||||
icon: 'Setting',
|
||||
path: '/system',
|
||||
children: [
|
||||
{ title: '系统版本管理', path: '/system/versions' },
|
||||
{ title: '缓存管理', path: '/system/cache' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '操作日志',
|
||||
icon: 'Document',
|
||||
path: '/audit-log',
|
||||
children: [
|
||||
{ title: '权限变更日志', path: '/audit-log/permission' },
|
||||
{ title: '账号操作日志', path: '/audit-log/account' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const defaultActive = computed(() => route.path)
|
||||
|
||||
function handleSelect(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="logo" :class="{ collapsed: sidebarCollapsed }">
|
||||
<h1 v-if="!sidebarCollapsed">超级管理员</h1>
|
||||
<h1 v-else>SA</h1>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="defaultActive"
|
||||
:collapse="sidebarCollapsed"
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
:unique-opened="true"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<el-sub-menu v-for="menu in menuList" :key="menu.path" :index="menu.path">
|
||||
<template #title>
|
||||
<el-icon><component :is="menu.icon" /></el-icon>
|
||||
<span>{{ menu.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in menu.children"
|
||||
:key="child.path"
|
||||
:index="child.path"
|
||||
>
|
||||
{{ child.title }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #263445;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.collapsed h1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore, type TagView } from '@/stores/global/useAppStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (route.name && route.meta.title) {
|
||||
appStore.addView({
|
||||
name: route.name as string,
|
||||
path: route.path,
|
||||
title: route.meta.title as string,
|
||||
keepAlive: route.meta.keepAlive as boolean,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleClose(path: string) {
|
||||
appStore.removeView(path)
|
||||
const views = appStore.visitedViews
|
||||
if (route.path === path) {
|
||||
const lastView = views[views.length - 1]
|
||||
if (lastView) {
|
||||
router.push(lastView.path)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags-view">
|
||||
<el-scrollbar>
|
||||
<div class="tags-container">
|
||||
<div
|
||||
v-for="tag in appStore.visitedViews"
|
||||
:key="tag.path"
|
||||
class="tag-item"
|
||||
:class="{ active: tag.path === route.path }"
|
||||
@click="handleClick(tag.path)"
|
||||
>
|
||||
<span class="tag-title">{{ tag.title }}</span>
|
||||
<el-icon class="tag-close" @click.stop="handleClose(tag.path)">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tags-view {
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
height: 26px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: #fff;
|
||||
|
||||
&.active {
|
||||
color: #409EFF;
|
||||
border-color: #409EFF;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
font-size: 12px;
|
||||
border-radius: 50%;
|
||||
&:hover {
|
||||
background: #c0c4cc;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,36 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import './styles/reset.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 注册 v-hasPermission 自定义指令
|
||||
app.directive('hasPermission', {
|
||||
mounted(el: HTMLElement, binding) {
|
||||
const permissionCode = binding.value as string
|
||||
if (!permissionCode) return
|
||||
// 延迟检查,确保 store 已初始化
|
||||
import('./utils/permission').then(({ hasPermission }) => {
|
||||
if (!hasPermission(permissionCode)) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@ -0,0 +1,26 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import superAdminRoutes from './modules/super-admin'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
...superAdminRoutes,
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '页面不存在' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@ -0,0 +1,173 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
|
||||
const superAdminRoutes: RouteRecordRaw[] = [
|
||||
// === 账号管理 ===
|
||||
{
|
||||
path: '/account',
|
||||
name: 'Account',
|
||||
component: AdminLayout,
|
||||
meta: { title: '账号管理', icon: 'User' },
|
||||
redirect: '/account/hospitals',
|
||||
children: [
|
||||
{
|
||||
path: 'hospitals',
|
||||
name: 'HospitalList',
|
||||
component: () => import('@/views/super-admin/account/HospitalList.vue'),
|
||||
meta: { title: '医院信息管理', permissions: ['permission:user:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'hospitals/create',
|
||||
name: 'HospitalCreate',
|
||||
component: () => import('@/views/super-admin/account/HospitalForm.vue'),
|
||||
meta: { title: '新增医院', permissions: ['permission:user:create'] },
|
||||
},
|
||||
{
|
||||
path: 'hospitals/:id/edit',
|
||||
name: 'HospitalEdit',
|
||||
component: () => import('@/views/super-admin/account/HospitalForm.vue'),
|
||||
meta: { title: '编辑医院', permissions: ['permission:user:update'] },
|
||||
},
|
||||
{
|
||||
path: 'property-companies',
|
||||
name: 'PropertyCompanyList',
|
||||
component: () => import('@/views/super-admin/account/PropertyCompanyList.vue'),
|
||||
meta: { title: '物业公司信息管理', permissions: ['permission:user:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'property-companies/create',
|
||||
name: 'PropertyCompanyCreate',
|
||||
component: () => import('@/views/super-admin/account/PropertyCompanyForm.vue'),
|
||||
meta: { title: '新增物业公司', permissions: ['permission:user:create'] },
|
||||
},
|
||||
{
|
||||
path: 'property-companies/:id/edit',
|
||||
name: 'PropertyCompanyEdit',
|
||||
component: () => import('@/views/super-admin/account/PropertyCompanyForm.vue'),
|
||||
meta: { title: '编辑物业公司', permissions: ['permission:user:update'] },
|
||||
},
|
||||
{
|
||||
path: 'hospital-accounts',
|
||||
name: 'HospitalAccountList',
|
||||
component: () => import('@/views/super-admin/account/HospitalAccountList.vue'),
|
||||
meta: { title: '医院账号管理', permissions: ['permission:user:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'hospital-accounts/create',
|
||||
name: 'HospitalAccountCreate',
|
||||
component: () => import('@/views/super-admin/account/HospitalAccountCreate.vue'),
|
||||
meta: { title: '新增医院账号', permissions: ['permission:user:create'] },
|
||||
},
|
||||
{
|
||||
path: 'property-accounts',
|
||||
name: 'PropertyAccountList',
|
||||
component: () => import('@/views/super-admin/account/PropertyAccountList.vue'),
|
||||
meta: { title: '物业管理员账号管理', permissions: ['permission:user:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'property-accounts/create',
|
||||
name: 'PropertyAccountCreate',
|
||||
component: () => import('@/views/super-admin/account/PropertyAccountCreate.vue'),
|
||||
meta: { title: '新增物业管理员账号', permissions: ['permission:user:create'] },
|
||||
},
|
||||
{
|
||||
path: 'expiring',
|
||||
name: 'ExpiringAccountList',
|
||||
component: () => import('@/views/super-admin/account/ExpiringAccountList.vue'),
|
||||
meta: { title: '到期账号管理', permissions: ['permission:user:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'expiry-settings',
|
||||
name: 'ExpirySettings',
|
||||
component: () => import('@/views/super-admin/account/ExpirySettings.vue'),
|
||||
meta: { title: '到期提醒规则配置', permissions: ['permission:config:view'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
// === 权限管理 ===
|
||||
{
|
||||
path: '/permission',
|
||||
name: 'Permission',
|
||||
component: AdminLayout,
|
||||
meta: { title: '权限管理', icon: 'Lock' },
|
||||
redirect: '/permission/roles',
|
||||
children: [
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'RoleList',
|
||||
component: () => import('@/views/super-admin/permission/RoleList.vue'),
|
||||
meta: { title: '角色管理', permissions: ['permission:role:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'roles/create',
|
||||
name: 'RoleCreate',
|
||||
component: () => import('@/views/super-admin/permission/RoleForm.vue'),
|
||||
meta: { title: '新增角色', permissions: ['permission:role:create'] },
|
||||
},
|
||||
{
|
||||
path: 'roles/:id/edit',
|
||||
name: 'RoleEdit',
|
||||
component: () => import('@/views/super-admin/permission/RoleForm.vue'),
|
||||
meta: { title: '编辑角色', permissions: ['permission:role:update'] },
|
||||
},
|
||||
{
|
||||
path: 'registry',
|
||||
name: 'PermissionRegistry',
|
||||
component: () => import('@/views/super-admin/permission/PermissionRegistry.vue'),
|
||||
meta: { title: '权限配置注册', permissions: ['permission:config:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'audit-log',
|
||||
name: 'PermissionAuditLog',
|
||||
component: () => import('@/views/super-admin/permission/PermissionAuditLog.vue'),
|
||||
meta: { title: '权限审计日志', permissions: ['audit-log:permission:view'], keepAlive: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
// === 系统配置 ===
|
||||
{
|
||||
path: '/system',
|
||||
name: 'System',
|
||||
component: AdminLayout,
|
||||
meta: { title: '系统配置', icon: 'Setting' },
|
||||
redirect: '/system/versions',
|
||||
children: [
|
||||
{
|
||||
path: 'versions',
|
||||
name: 'VersionList',
|
||||
component: () => import('@/views/super-admin/system/VersionManagement.vue'),
|
||||
meta: { title: '系统版本管理', permissions: ['system:version:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'cache',
|
||||
name: 'CacheManagement',
|
||||
component: () => import('@/views/super-admin/system/CacheManagement.vue'),
|
||||
meta: { title: '缓存管理', permissions: ['system:cache:view'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
// === 操作日志 ===
|
||||
{
|
||||
path: '/audit-log',
|
||||
name: 'AuditLog',
|
||||
component: AdminLayout,
|
||||
meta: { title: '操作日志', icon: 'Document' },
|
||||
redirect: '/audit-log/permission',
|
||||
children: [
|
||||
{
|
||||
path: 'permission',
|
||||
name: 'AuditLogPermission',
|
||||
component: () => import('@/views/super-admin/audit-log/PermissionChangeLog.vue'),
|
||||
meta: { title: '权限变更日志', permissions: ['audit-log:permission:view'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'AuditLogAccount',
|
||||
component: () => import('@/views/super-admin/audit-log/AccountOperationLog.vue'),
|
||||
meta: { title: '账号操作日志', permissions: ['audit-log:list:view'], keepAlive: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default superAdminRoutes
|
||||
@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface TagView {
|
||||
name: string
|
||||
path: string
|
||||
title: string
|
||||
icon?: string
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const visitedViews = ref<TagView[]>([])
|
||||
const cachedViews = ref<string[]>([])
|
||||
|
||||
function addView(view: TagView) {
|
||||
if (visitedViews.value.some(v => v.path === view.path)) return
|
||||
visitedViews.value.push(view)
|
||||
if (view.keepAlive && !cachedViews.value.includes(view.name)) {
|
||||
cachedViews.value.push(view.name)
|
||||
}
|
||||
}
|
||||
|
||||
function removeView(path: string) {
|
||||
const idx = visitedViews.value.findIndex(v => v.path === path)
|
||||
if (idx > -1) visitedViews.value.splice(idx, 1)
|
||||
const name = visitedViews.value[idx]?.name
|
||||
if (name) {
|
||||
const cacheIdx = cachedViews.value.indexOf(name)
|
||||
if (cacheIdx > -1) cachedViews.value.splice(cacheIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function removeOtherViews(path: string) {
|
||||
visitedViews.value = visitedViews.value.filter(v => v.path === path)
|
||||
cachedViews.value = cachedViews.value.filter(name =>
|
||||
visitedViews.value.some(v => v.name === name)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
visitedViews,
|
||||
cachedViews,
|
||||
addView,
|
||||
removeView,
|
||||
removeOtherViews,
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref({
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
realName: '超级管理员',
|
||||
avatar: '',
|
||||
})
|
||||
|
||||
const permissions = ref<string[]>([
|
||||
// 账号管理权限
|
||||
'permission:user:view',
|
||||
'permission:user:create',
|
||||
'permission:user:update',
|
||||
'permission:user:delete',
|
||||
// 权限管理权限
|
||||
'permission:role:view',
|
||||
'permission:role:create',
|
||||
'permission:role:update',
|
||||
'permission:role:delete',
|
||||
'permission:config:view',
|
||||
'permission:config:update',
|
||||
// 系统配置权限
|
||||
'system:version:view',
|
||||
'system:version:create',
|
||||
'system:version:update',
|
||||
'system:cache:view',
|
||||
'system:cache:update',
|
||||
// 操作日志权限
|
||||
'audit-log:permission:view',
|
||||
'audit-log:list:view',
|
||||
])
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
function hasPermission(code: string): boolean {
|
||||
return permissions.value.includes(code)
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
permissions,
|
||||
sidebarCollapsed,
|
||||
hasPermission,
|
||||
toggleSidebar,
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
// 样式重置
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: $--font-size-base;
|
||||
color: #333;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
// 滚动条
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c0c4cc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 页面容器
|
||||
.page-container {
|
||||
padding: $--spacing-page;
|
||||
}
|
||||
|
||||
// 查询条件区域
|
||||
.query-panel {
|
||||
background: #fff;
|
||||
padding: $--spacing-section;
|
||||
border-radius: 4px;
|
||||
margin-bottom: $--spacing-item;
|
||||
}
|
||||
|
||||
// 操作栏区域
|
||||
.action-bar {
|
||||
background: #fff;
|
||||
padding: $--spacing-item $--spacing-section;
|
||||
border-radius: 4px;
|
||||
margin-bottom: $--spacing-item;
|
||||
}
|
||||
|
||||
// 表格区域
|
||||
.data-table-wrapper {
|
||||
background: #fff;
|
||||
padding: $--spacing-section;
|
||||
border-radius: 4px;
|
||||
margin-bottom: $--spacing-item;
|
||||
}
|
||||
|
||||
// 分页
|
||||
.pagination-wrapper {
|
||||
background: #fff;
|
||||
padding: $--spacing-item $--spacing-section;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
// Element Plus主题变量覆盖
|
||||
|
||||
// 品牌色
|
||||
$--color-primary: #409EFF;
|
||||
$--color-success: #67C23A;
|
||||
$--color-warning: #E6A23C;
|
||||
$--color-danger: #F56C6C;
|
||||
$--color-info: #909399;
|
||||
|
||||
// 布局
|
||||
$--layout-sidebar-width: 220px;
|
||||
$--layout-sidebar-collapsed-width: 64px;
|
||||
$--layout-header-height: 56px;
|
||||
$--layout-tags-height: 36px;
|
||||
$--layout-footer-height: 48px;
|
||||
|
||||
// 字体
|
||||
$--font-size-base: 14px;
|
||||
$--font-size-small: 12px;
|
||||
$--font-size-large: 16px;
|
||||
|
||||
// 间距
|
||||
$--spacing-page: 20px;
|
||||
$--spacing-section: 16px;
|
||||
$--spacing-item: 12px;
|
||||
@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface PendingRequest {
|
||||
key: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
/** H1 防重复提交 */
|
||||
export function useDebounceSubmit() {
|
||||
const submitLoading = ref(false)
|
||||
|
||||
async function debounceSubmit<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
options?: { cooldown?: number }
|
||||
): Promise<T | null> {
|
||||
if (submitLoading.value) return null
|
||||
|
||||
const pending = pendingRequests.get(key)
|
||||
const cooldown = options?.cooldown ?? 1000
|
||||
if (pending && Date.now() - pending.timestamp < cooldown) {
|
||||
return null
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
pendingRequests.set(key, { key, timestamp: Date.now() })
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
pendingRequests.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { submitLoading, debounceSubmit }
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
/** H4 脏数据检测 */
|
||||
export function useDirtyCheck<T extends Record<string, any>>(
|
||||
formData: Ref<T>,
|
||||
) {
|
||||
const initialSnapshot = ref('')
|
||||
const isDirty = ref(false)
|
||||
|
||||
function saveSnapshot() {
|
||||
initialSnapshot.value = JSON.stringify(formData.value)
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
watch(formData, () => {
|
||||
if (initialSnapshot.value) {
|
||||
isDirty.value = JSON.stringify(formData.value) !== initialSnapshot.value
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isDirty.value) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
async function checkBeforeLeave(): Promise<boolean> {
|
||||
if (!isDirty.value) return true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '离开',
|
||||
cancelButtonText: '留在此页',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { isDirty, saveSnapshot, checkBeforeLeave }
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { useUserStore } from '@/stores/global/useUserStore'
|
||||
|
||||
/** 权限校验 */
|
||||
export function hasPermission(code: string): boolean {
|
||||
const userStore = useUserStore()
|
||||
return userStore.hasPermission(code)
|
||||
}
|
||||
|
||||
/** 权限指令辅助 */
|
||||
export function checkPermissions(codes: string[]): boolean {
|
||||
if (!codes || codes.length === 0) return true
|
||||
return codes.every(code => hasPermission(code))
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
/** H3 操作确认 */
|
||||
export async function confirmAction(
|
||||
message: string,
|
||||
title = '操作确认',
|
||||
type: 'warning' | 'error' = 'warning'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** H8 操作成功反馈 */
|
||||
export function showSuccess(message = '操作成功', duration = 2000) {
|
||||
ElMessage.success({ message, duration })
|
||||
}
|
||||
|
||||
/** H8 操作失败反馈 */
|
||||
export function showError(message = '操作失败,请稍后重试', duration = 0) {
|
||||
ElMessage.error({ message, duration })
|
||||
}
|
||||
|
||||
/** H8 网络异常反馈 */
|
||||
export function showNetworkError() {
|
||||
ElMessage.error({
|
||||
message: '网络连接异常,请检查网络后重试',
|
||||
duration: 0,
|
||||
showClose: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** H8 超时反馈 */
|
||||
export function showTimeoutError() {
|
||||
ElMessage.error({
|
||||
message: '请求超时,请检查网络后重试',
|
||||
duration: 0,
|
||||
showClose: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 统一操作结果反馈 */
|
||||
export function showResultFeedback(type: 'success' | 'error', message: string) {
|
||||
if (type === 'success') {
|
||||
showSuccess(message)
|
||||
} else {
|
||||
showError(message)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h2>超级管理员登录</h2>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="0" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="formData.username" prefix-icon="User" placeholder="请输入账号" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="formData.password" prefix-icon="Lock" type="password" placeholder="请输入密码" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" native-type="submit" size="large" style="width: 100%">登 录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive({ username: '', password: '' })
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
// Mock login
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
router.push('/account/hospitals')
|
||||
}, 500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px; padding: 40px; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.1);
|
||||
h2 { text-align: center; margin-bottom: 24px; color: #303133; }
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="expiring-account-list">
|
||||
<Breadcrumb :items="[{ label: '账号管理' }, { label: '到期账号管理' }]" />
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="stats-row">
|
||||
<el-col :sm="8" :xs="24">
|
||||
<el-card shadow="hover" :body-style="{ padding: '20px', cursor: 'pointer' }" @click="filterByStatus('expired')">
|
||||
<div class="stat-label">已过期</div>
|
||||
<div class="stat-number" style="color: var(--el-color-danger)">{{ stats.expired }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :sm="8" :xs="24">
|
||||
<el-card shadow="hover" :body-style="{ padding: '20px', cursor: 'pointer' }" @click="filterByStatus('7days')">
|
||||
<div class="stat-label">7天内到期</div>
|
||||
<div class="stat-number" style="color: var(--el-color-warning)">{{ stats.expiringIn7Days }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :sm="8" :xs="24">
|
||||
<el-card shadow="hover" :body-style="{ padding: '20px', cursor: 'pointer' }" @click="filterByStatus('30days')">
|
||||
<div class="stat-label">30天内到期</div>
|
||||
<div class="stat-number" style="color: var(--el-color-primary)">{{ stats.expiringIn30Days }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
|
||||
<el-form-item label="账号类型">
|
||||
<el-select v-model="queryForm.accountType" clearable placeholder="请选择">
|
||||
<el-option label="医院" value="hospital" />
|
||||
<el-option label="物业管理员" value="property_admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期状态">
|
||||
<el-select v-model="queryForm.expireStatus" clearable placeholder="请选择">
|
||||
<el-option label="已过期" value="expired" />
|
||||
<el-option label="7天内到期" value="7days" />
|
||||
<el-option label="30天内到期" value="30days" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</QueryPanel>
|
||||
|
||||
<DataTable :loading="loading" :data="tableData" stripe border>
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="username" label="登录账号" min-width="130" />
|
||||
<el-table-column label="账号类型" width="100">
|
||||
<template #default="{ row }">{{ row.accountType === 'hospital' ? '医院' : '物业' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bindUnit" label="绑定单位" width="150" />
|
||||
<el-table-column prop="expireDate" label="有效期至" width="120" sortable />
|
||||
<el-table-column label="剩余天数" width="90" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRemainTagType(row.remainDays)">{{ row.remainDays > 0 ? row.remainDays + '天' : row.remainDays + '天' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" :loading="row._loading" @click="handleRenew(row)">续期</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</DataTable>
|
||||
|
||||
<Pagination :pagination="pagination" @change="handlePageChange" />
|
||||
|
||||
<!-- 续期弹窗 -->
|
||||
<el-dialog v-model="renewDialogVisible" title="账号续期" width="400px" :close-on-click-modal="false">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="登录账号">{{ currentRow?.username }}</el-form-item>
|
||||
<el-form-item label="新有效期">
|
||||
<el-date-picker v-model="renewDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="renewDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="renewLoading" @click="confirmRenew">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { accountApi } from '@/api'
|
||||
import type { ExpiringAccount, ExpiringAccountQuery, ExpiringStats } from '@/api/types/account'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
|
||||
import DataTable from '@/components/shared/DataTable/index.vue'
|
||||
import Pagination from '@/components/shared/Pagination/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<(ExpiringAccount & { _loading?: boolean })[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
const stats = reactive<ExpiringStats>({ expired: 0, expiringIn7Days: 0, expiringIn30Days: 0 })
|
||||
|
||||
const queryForm = reactive<ExpiringAccountQuery>({
|
||||
page: 1, pageSize: 20, accountType: '', expireStatus: ''
|
||||
})
|
||||
|
||||
const renewDialogVisible = ref(false)
|
||||
const renewLoading = ref(false)
|
||||
const renewDate = ref('')
|
||||
const currentRow = ref<ExpiringAccount | null>(null)
|
||||
|
||||
function getRemainTagType(days: number) {
|
||||
if (days < 0) return 'danger'
|
||||
if (days <= 7) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await accountApi.getExpiringAccountList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
|
||||
tableData.value = res.list
|
||||
pagination.total = res.pagination.total
|
||||
pagination.totalPages = res.pagination.totalPages
|
||||
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await accountApi.getExpiringStats()
|
||||
Object.assign(stats, s)
|
||||
} catch { /* stats load failure is non-blocking */ }
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.page = 1; loadData(); loadStats() }
|
||||
function handleReset() {
|
||||
queryForm.accountType = ''; queryForm.expireStatus = ''
|
||||
pagination.page = 1; loadData(); loadStats()
|
||||
}
|
||||
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
|
||||
|
||||
function filterByStatus(status: string) {
|
||||
queryForm.expireStatus = status
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleRenew(row: ExpiringAccount & { _loading?: boolean }) {
|
||||
currentRow.value = row
|
||||
renewDate.value = ''
|
||||
renewDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmRenew() {
|
||||
if (!renewDate.value) { showResultFeedback('error', '请选择有效期'); return }
|
||||
if (new Date(renewDate.value) < new Date()) { showResultFeedback('error', '有效期不能早于当前日期'); return }
|
||||
renewLoading.value = true
|
||||
try {
|
||||
await accountApi.renewAccount(currentRow.value!.id, renewDate.value)
|
||||
showResultFeedback('success', '续期成功')
|
||||
renewDialogVisible.value = false
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (e: any) { showResultFeedback('error', e.message || '续期失败') }
|
||||
finally { renewLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); loadStats() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stats-row { margin-bottom: 16px; }
|
||||
.stat-label { font-size: 14px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
|
||||
.stat-number { font-size: 28px; font-weight: bold; }
|
||||
</style>
|
||||
@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="hospital-account-create">
|
||||
<Breadcrumb :items="[{ label: '账号管理' }, { label: '医院账号管理' }, { label: '新增医院账号' }]" />
|
||||
|
||||
<el-card>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="登录账号" prop="username">
|
||||
<el-input v-model="formData.username" maxlength="20" clearable placeholder="4-20位字母数字" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="初始密码" prop="password">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input v-model="formData.password" maxlength="20" show-password placeholder="6-20位" style="flex: 1" />
|
||||
<el-button :icon="Refresh" circle @click="generatePassword" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="绑定医院" prop="hospitalId">
|
||||
<el-select v-model="formData.hospitalId" filterable clearable placeholder="请选择医院" style="width: 100%">
|
||||
<el-option v-for="h in hospitalOptions" :key="h.id" :label="h.name" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="有效期至" prop="expireDate">
|
||||
<el-date-picker v-model="formData.expireDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="分配角色" prop="roleIds">
|
||||
<el-select v-model="formData.roleIds" multiple filterable collapse-tags placeholder="请选择角色" style="width: 100%">
|
||||
<el-option v-for="r in roleOptions" :key="r.id" :label="r.name" :value="r.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '保存中...' : '保存' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { accountApi } from '@/api'
|
||||
import type { HospitalAccountFormData } from '@/api/types/account'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
import { useDirtyCheck } from '@/utils/dirty-check'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
const hospitalOptions = ref<{ id: string; name: string }[]>([])
|
||||
const roleOptions = ref<{ id: string; name: string }[]>([])
|
||||
|
||||
function generatePwd() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
return 'Abc' + Array.from({ length: 5 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
}
|
||||
|
||||
const formData = reactive<HospitalAccountFormData>({
|
||||
username: '', password: generatePwd(), hospitalId: '', expireDate: '', roleIds: []
|
||||
})
|
||||
|
||||
let snapshot = JSON.parse(JSON.stringify(formData))
|
||||
const { isDirty } = useDirtyCheck(formData, () => snapshot)
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9]{4,20}$/, message: '登录账号为4-20位字母数字', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度6-20位', trigger: 'blur' }
|
||||
],
|
||||
hospitalId: [{ required: true, message: '请选择绑定医院', trigger: 'change' }],
|
||||
expireDate: [{ required: true, message: '请选择有效期', trigger: 'change' }],
|
||||
roleIds: [{ type: 'array', required: true, message: '请选择至少一个角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
formData.password = generatePwd()
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
if (new Date(formData.expireDate) < new Date()) {
|
||||
showResultFeedback('error', '有效期不能早于当前日期')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await accountApi.createHospitalAccount(formData)
|
||||
showResultFeedback('success', '保存成功')
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
setTimeout(() => router.push('/account/hospital-accounts'), 300)
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => router.push('/account/hospital-accounts')).catch(() => {})
|
||||
} else {
|
||||
router.push('/account/hospital-accounts')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (saving.value) { next(false); return }
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => next()).catch(() => next(false))
|
||||
} else { next() }
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
hospitalOptions.value = await accountApi.getHospitalOptions()
|
||||
// Mock role options for hospital scope
|
||||
roleOptions.value = [
|
||||
{ id: 'role-1', name: '医院查看模板' },
|
||||
{ id: 'role-2', name: '医院管理模板' },
|
||||
{ id: 'role-3', name: '医院审批模板' }
|
||||
]
|
||||
// Default expire date: +1 year
|
||||
const d = new Date()
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
formData.expireDate = d.toISOString().split('T')[0]
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
</style>
|
||||
@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="hospital-form">
|
||||
<Breadcrumb :items="[{ label: '账号管理' }, { label: '医院信息管理' }, { label: isEdit ? '编辑医院' : '新增医院' }]" />
|
||||
|
||||
<el-card v-loading="detailLoading">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="医院名称" prop="name">
|
||||
<el-input v-model="formData.name" maxlength="50" show-word-limit clearable placeholder="请输入医院名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="统一社会信用代码" prop="creditCode">
|
||||
<el-input v-model="formData.creditCode" maxlength="18" show-word-limit clearable placeholder="18位统一社会信用代码" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio value="enabled">启用</el-radio>
|
||||
<el-radio value="disabled">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="医院地址" prop="address">
|
||||
<el-input v-model="formData.address" maxlength="200" show-word-limit placeholder="请输入医院地址" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="联系人" prop="contactPerson">
|
||||
<el-input v-model="formData.contactPerson" maxlength="20" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="联系电话" prop="contactPhone">
|
||||
<el-input v-model="formData.contactPhone" maxlength="11" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<h3 class="section-title" style="margin-top: 24px">院区信息</h3>
|
||||
<el-button type="primary" plain :icon="Plus" @click="addCampus" style="margin-bottom: 12px">添加院区</el-button>
|
||||
<div v-for="(campus, idx) in formData.campuses" :key="idx" class="campus-row">
|
||||
<el-row :gutter="12">
|
||||
<el-col :sm="24" :md="6">
|
||||
<el-form-item :label="`院区${idx + 1}名称`" :prop="`campuses.${idx}.name`" :rules="campusNameRules">
|
||||
<el-input v-model="campus.name" maxlength="30" show-word-limit placeholder="请输入院区名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="8">
|
||||
<el-form-item label="地址" :prop="`campuses.${idx}.address`" :rules="campusAddressRules">
|
||||
<el-input v-model="campus.address" maxlength="200" placeholder="请输入院区地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="6">
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="campus.contactPerson" maxlength="20" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="4" class="campus-action">
|
||||
<el-button type="danger" link :icon="Delete" :disabled="formData.campuses.length <= 1" @click="removeCampus(idx)">删除</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '保存中...' : '保存' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { accountApi } from '@/api'
|
||||
import type { HospitalFormData, HospitalCampus } from '@/api/types/account'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
import { useDirtyCheck } from '@/utils/dirty-check'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const defaultCampus: HospitalCampus = { name: '', address: '', contactPerson: '' }
|
||||
const formData = reactive<HospitalFormData>({
|
||||
name: '',
|
||||
address: '',
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
status: 'enabled',
|
||||
campuses: [{ ...defaultCampus }]
|
||||
})
|
||||
|
||||
let snapshot = JSON.parse(JSON.stringify(formData))
|
||||
const { isDirty, checkBeforeLeave } = useDirtyCheck(formData, () => snapshot)
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入医院名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '医院名称长度2-50字符', trigger: 'blur' }
|
||||
],
|
||||
creditCode: [
|
||||
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
||||
{ pattern: /^[0-9A-Z]{18}$/, message: '统一社会信用代码为18位字母数字', trigger: 'blur' }
|
||||
],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
address: [{ max: 200, message: '医院地址不能超过200字符', trigger: 'blur' }],
|
||||
contactPerson: [{ max: 20, message: '联系人不能超过20字符', trigger: 'blur' }],
|
||||
contactPhone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const campusNameRules = [
|
||||
{ required: true, message: '请输入院区名称', trigger: 'blur' },
|
||||
{ min: 2, max: 30, message: '院区名称长度2-30字符', trigger: 'blur' }
|
||||
]
|
||||
const campusAddressRules = [
|
||||
{ required: true, message: '请输入院区地址', trigger: 'blur' },
|
||||
{ max: 200, message: '院区地址不能超过200字符', trigger: 'blur' }
|
||||
]
|
||||
|
||||
function addCampus() {
|
||||
formData.campuses.push({ ...defaultCampus })
|
||||
}
|
||||
|
||||
async function removeCampus(idx: number) {
|
||||
if (formData.campuses.length <= 1) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该院区吗?删除后数据无法恢复', '删除确认', { type: 'warning' })
|
||||
formData.campuses.splice(idx, 1)
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
if (formData.campuses.length === 0) {
|
||||
ElMessage.error('请至少添加一个院区')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await accountApi.updateHospital(route.params.id as string, formData)
|
||||
} else {
|
||||
await accountApi.createHospital(formData)
|
||||
}
|
||||
showResultFeedback('success', '保存成功')
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
setTimeout(() => router.push('/account/hospitals'), 300)
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => router.push('/account/hospitals'))
|
||||
.catch(() => {})
|
||||
} else {
|
||||
router.push('/account/hospitals')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (saving.value) { next(false); return }
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => next()).catch(() => next(false))
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const detail = await accountApi.getHospitalDetail(route.params.id as string)
|
||||
Object.assign(formData, {
|
||||
name: detail.name,
|
||||
creditCode: detail.creditCode,
|
||||
address: detail.address,
|
||||
contactPerson: detail.contactPerson,
|
||||
contactPhone: detail.contactPhone,
|
||||
status: detail.status,
|
||||
campuses: detail.campuses.length ? detail.campuses : [{ ...defaultCampus }]
|
||||
})
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '数据加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.campus-row {
|
||||
background: var(--el-fill-color-lighter);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.campus-action {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
.form-footer {
|
||||
text-align: right;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="property-account-create">
|
||||
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业管理员账号管理' }, { label: '新增物业管理员账号' }]" />
|
||||
|
||||
<el-card>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="saving">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="登录账号" prop="username">
|
||||
<el-input v-model="formData.username" maxlength="20" clearable placeholder="4-20位字母数字" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="初始密码" prop="password">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input v-model="formData.password" maxlength="20" show-password placeholder="6-20位" style="flex: 1" />
|
||||
<el-button :icon="Refresh" circle @click="generatePassword" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="绑定物业公司" prop="propertyCompanyId">
|
||||
<el-select v-model="formData.propertyCompanyId" filterable clearable placeholder="请选择物业公司" style="width: 100%" @change="handleCompanyChange">
|
||||
<el-option v-for="p in propertyCompanyOptions" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="服务医院" prop="hospitalId">
|
||||
<el-select v-model="formData.hospitalId" filterable clearable placeholder="请选择医院" style="width: 100%">
|
||||
<el-option v-for="h in filteredHospitalOptions" :key="h.id" :label="h.name" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="有效期至" prop="expireDate">
|
||||
<el-date-picker v-model="formData.expireDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" :disabled-date="(d: Date) => d < new Date()" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="分配角色" prop="roleIds">
|
||||
<el-select v-model="formData.roleIds" multiple filterable collapse-tags placeholder="请选择角色" style="width: 100%">
|
||||
<el-option v-for="r in roleOptions" :key="r.id" :label="r.name" :value="r.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '保存中...' : '保存' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { accountApi } from '@/api'
|
||||
import type { PropertyAccountFormData } from '@/api/types/account'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
const propertyCompanyOptions = ref<{ id: string; name: string }[]>([])
|
||||
const hospitalOptions = ref<{ id: string; name: string }[]>([])
|
||||
const roleOptions = ref<{ id: string; name: string }[]>([])
|
||||
|
||||
function generatePwd() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
return 'Abc' + Array.from({ length: 5 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
}
|
||||
|
||||
const formData = reactive<PropertyAccountFormData>({
|
||||
username: '', password: generatePwd(), propertyCompanyId: '', hospitalId: '', expireDate: '', roleIds: []
|
||||
})
|
||||
|
||||
let snapshot = JSON.parse(JSON.stringify(formData))
|
||||
let isDirty = ref(false)
|
||||
|
||||
// Simple dirty check
|
||||
function checkDirty() {
|
||||
isDirty.value = JSON.stringify(formData) !== snapshot
|
||||
}
|
||||
|
||||
const filteredHospitalOptions = computed(() => {
|
||||
if (!formData.propertyCompanyId) return hospitalOptions.value
|
||||
// Mock: show all hospitals when a company is selected (real API would filter)
|
||||
return hospitalOptions.value
|
||||
})
|
||||
|
||||
function handleCompanyChange() {
|
||||
formData.hospitalId = ''
|
||||
checkDirty()
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9]{4,20}$/, message: '登录账号为4-20位字母数字', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度6-20位', trigger: 'blur' }
|
||||
],
|
||||
propertyCompanyId: [{ required: true, message: '请选择绑定物业公司', trigger: 'change' }],
|
||||
hospitalId: [{ required: true, message: '请选择服务医院', trigger: 'change' }],
|
||||
expireDate: [{ required: true, message: '请选择有效期', trigger: 'change' }],
|
||||
roleIds: [{ type: 'array', required: true, message: '请选择至少一个角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
formData.password = generatePwd()
|
||||
checkDirty()
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
if (new Date(formData.expireDate) < new Date()) {
|
||||
showResultFeedback('error', '有效期不能早于当前日期')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await accountApi.createPropertyAccount(formData)
|
||||
showResultFeedback('success', '保存成功')
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
isDirty.value = false
|
||||
setTimeout(() => router.push('/account/property-accounts'), 300)
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => router.push('/account/property-accounts')).catch(() => {})
|
||||
} else {
|
||||
router.push('/account/property-accounts')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (saving.value) { next(false); return }
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => next()).catch(() => next(false))
|
||||
} else { next() }
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
propertyCompanyOptions.value = await accountApi.getPropertyCompanyOptions()
|
||||
hospitalOptions.value = await accountApi.getHospitalOptions()
|
||||
roleOptions.value = [
|
||||
{ id: 'role-4', name: '物业管理员模板' },
|
||||
{ id: 'role-5', name: '主管模板' },
|
||||
{ id: 'role-6', name: '班组长模板' },
|
||||
{ id: 'role-7', name: '维修员模板' }
|
||||
]
|
||||
const d = new Date()
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
formData.expireDate = d.toISOString().split('T')[0]
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
</style>
|
||||
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="property-company-form">
|
||||
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业公司信息管理' }, { label: isEdit ? '编辑物业公司' : '新增物业公司' }]" />
|
||||
|
||||
<el-card v-loading="detailLoading">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="公司名称" prop="name">
|
||||
<el-input v-model="formData.name" maxlength="50" show-word-limit clearable placeholder="请输入公司名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="联系人" prop="contactPerson">
|
||||
<el-input v-model="formData.contactPerson" maxlength="20" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="公司地址" prop="address">
|
||||
<el-input v-model="formData.address" maxlength="200" show-word-limit placeholder="请输入公司地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="contactPhone">
|
||||
<el-input v-model="formData.contactPhone" maxlength="11" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '保存中...' : '保存' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { accountApi } from '@/api'
|
||||
import type { PropertyCompanyFormData } from '@/api/types/account'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
import { useDirtyCheck } from '@/utils/dirty-check'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const formData = reactive<PropertyCompanyFormData>({
|
||||
name: '', address: '', contactPerson: '', contactPhone: ''
|
||||
})
|
||||
|
||||
let snapshot = JSON.parse(JSON.stringify(formData))
|
||||
const { isDirty } = useDirtyCheck(formData, () => snapshot)
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '公司名称长度2-50字符', trigger: 'blur' }
|
||||
],
|
||||
address: [{ max: 200, message: '公司地址不能超过200字符', trigger: 'blur' }],
|
||||
contactPerson: [
|
||||
{ required: true, message: '请输入联系人', trigger: 'blur' },
|
||||
{ max: 20, message: '联系人不能超过20字符', trigger: 'blur' }
|
||||
],
|
||||
contactPhone: [
|
||||
{ required: true, message: '请输入联系电话', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await accountApi.updatePropertyCompany(route.params.id as string, formData)
|
||||
} else {
|
||||
await accountApi.createPropertyCompany(formData)
|
||||
}
|
||||
showResultFeedback('success', '保存成功')
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
setTimeout(() => router.push('/account/property-companies'), 300)
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => router.push('/account/property-companies')).catch(() => {})
|
||||
} else {
|
||||
router.push('/account/property-companies')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (saving.value) { next(false); return }
|
||||
if (isDirty.value) {
|
||||
ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' })
|
||||
.then(() => next()).catch(() => next(false))
|
||||
} else { next() }
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const detail = await accountApi.getPropertyCompanyDetail(route.params.id as string)
|
||||
Object.assign(formData, { name: detail.name, address: detail.address, contactPerson: detail.contactPerson, contactPhone: detail.contactPhone })
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '数据加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-footer { text-align: right; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
</style>
|
||||
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="property-company-list">
|
||||
<Breadcrumb :items="[{ label: '账号管理' }, { label: '物业公司信息管理' }]" />
|
||||
|
||||
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
|
||||
<el-form-item label="公司名称">
|
||||
<el-input v-model="queryForm.name" clearable maxlength="50" placeholder="请输入" @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" clearable placeholder="请选择">
|
||||
<el-option label="启用" value="enabled" />
|
||||
<el-option label="停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="queryForm.contactPerson" clearable maxlength="20" placeholder="请输入" @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
</QueryPanel>
|
||||
|
||||
<ActionBar>
|
||||
<el-button v-hasPermission="'permission:user:create'" type="primary" :icon="Plus" @click="$router.push('/account/property-companies/create')">
|
||||
新增物业公司
|
||||
</el-button>
|
||||
</ActionBar>
|
||||
|
||||
<DataTable :loading="loading" :data="tableData" stripe border>
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="name" label="公司名称" min-width="180" sortable />
|
||||
<el-table-column label="服务医院" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" underline="never" @click="showHospitals(row.serviceHospitals)">{{ row.serviceHospitals?.join('、') || '—' }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contactPerson" label="联系人" width="100" />
|
||||
<el-table-column label="联系电话" width="130">
|
||||
<template #default="{ row }">{{ maskPhone(row.contactPhone) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="150" sortable />
|
||||
<el-table-column label="状态" width="80" sortable prop="status">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :status="row.status === 'enabled' ? 'success' : 'danger'" :text="row.status === 'enabled' ? '启用' : '停用'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-hasPermission="'permission:user:update'" label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" :loading="row._loading" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="primary" link size="small" :loading="row._loading" @click="handleToggleStatus(row)">
|
||||
{{ row.status === 'enabled' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</DataTable>
|
||||
|
||||
<Pagination :pagination="pagination" @change="handlePageChange" />
|
||||
|
||||
<el-dialog v-model="hospitalDialogVisible" title="服务医院列表" width="400px">
|
||||
<div v-if="currentHospitals.length">
|
||||
<el-tag v-for="h in currentHospitals" :key="h" style="margin: 4px">{{ h }}</el-tag>
|
||||
</div>
|
||||
<el-empty v-else description="暂无关联医院" />
|
||||
<template #footer>
|
||||
<el-button @click="hospitalDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { accountApi } from '@/api'
|
||||
import type { PropertyCompany, PropertyCompanyQuery } from '@/api/types/account'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
|
||||
import ActionBar from '@/components/shared/ActionBar/index.vue'
|
||||
import DataTable from '@/components/shared/DataTable/index.vue'
|
||||
import Pagination from '@/components/shared/Pagination/index.vue'
|
||||
import StatusTag from '@/components/shared/StatusTag/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const tableData = ref<(PropertyCompany & { _loading?: boolean })[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
const hospitalDialogVisible = ref(false)
|
||||
const currentHospitals = ref<string[]>([])
|
||||
|
||||
const queryForm = reactive<PropertyCompanyQuery>({
|
||||
page: 1, pageSize: 20, name: '', status: '', contactPerson: ''
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await accountApi.getPropertyCompanyList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
|
||||
tableData.value = res.list
|
||||
pagination.total = res.pagination.total
|
||||
pagination.totalPages = res.pagination.totalPages
|
||||
} catch (e: any) {
|
||||
showResultFeedback('error', e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.page = 1; loadData() }
|
||||
function handleReset() { queryForm.name = ''; queryForm.status = ''; queryForm.contactPerson = ''; pagination.page = 1; loadData() }
|
||||
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
|
||||
|
||||
function handleEdit(row: PropertyCompany) {
|
||||
router.push(`/account/property-companies/${row.id}/edit`)
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row: PropertyCompany & { _loading?: boolean }) {
|
||||
const action = row.status === 'enabled' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${action}「${row.name}」吗?${action === '停用' ? '停用后该物业下所有账号将无法登录' : ''}`,
|
||||
'操作确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
row._loading = true
|
||||
await accountApi.togglePropertyCompanyStatus(row.id)
|
||||
showResultFeedback('success', `${action}成功`)
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败')
|
||||
} finally {
|
||||
row._loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function showHospitals(hospitals: string[]) {
|
||||
currentHospitals.value = hospitals || []
|
||||
hospitalDialogVisible.value = true
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (!phone || phone.length < 7) return phone
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="role-form">
|
||||
<Breadcrumb :items="[{ label: '权限管理' }, { label: '角色管理' }, { label: isEdit ? '编辑角色' : '新增角色' }]" />
|
||||
<el-card v-loading="detailLoading">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" :disabled="saving">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="角色名称" prop="name"><el-input v-model="formData.name" maxlength="30" show-word-limit clearable placeholder="请输入角色名称" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12">
|
||||
<el-form-item label="适用范围" prop="scope">
|
||||
<el-select v-model="formData.scope" clearable placeholder="请选择适用范围" style="width:100%">
|
||||
<el-option label="医院账号" value="hospital" /><el-option label="物业管理员" value="property_admin" /><el-option label="物业下属" value="property_staff" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="角色描述"><el-input v-model="formData.description" type="textarea" maxlength="200" show-word-limit :rows="3" placeholder="请输入角色描述" /></el-form-item>
|
||||
<el-form-item label="预设模板">
|
||||
<el-select v-model="formData.presetTemplate" clearable placeholder="可选,选择后自动填充权限" style="width:100%" @change="handleTemplateChange">
|
||||
<el-option v-for="t in presetTemplates" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<h3 class="section-title" style="margin-top:24px">权限分配</h3>
|
||||
<el-input v-model="treeSearch" placeholder="搜索权限名称" clearable prefix-icon="Search" style="margin-bottom:12px" />
|
||||
<div class="permission-tree-wrapper">
|
||||
<el-tree ref="treeRef" :data="permissionTree" show-checkbox check-strictly=false node-key="code" :default-expand-all="false"
|
||||
:filter-node-method="(val:string, data:any) => val ? data.name.includes(val) : true"
|
||||
:props="{ label: 'name', children: 'children' }" @check="handleCheck" />
|
||||
</div>
|
||||
</el-form>
|
||||
<div class="form-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="info" plain @click="handlePreview">权限预览</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">{{ saving ? '保存中...' : '保存' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 权限预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="权限预览" width="600px">
|
||||
<el-tree :data="permissionTree" default-expand-all node-key="code" :props="{ label: 'name', children: 'children' }">
|
||||
<template #default="{ data }">
|
||||
<span>{{ data.name }}</span>
|
||||
<el-tag v-if="data.type === 'action'" :type="currentCheckedKeys.includes(data.code) ? 'success' : 'info'" size="small" style="margin-left:8px">
|
||||
{{ currentCheckedKeys.includes(data.code) ? '✓' : '✗' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-tree>
|
||||
<template #footer><el-button @click="previewVisible=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { permissionApi } from '@/api'
|
||||
import type { RoleFormData, PermissionNode } from '@/api/types/permission'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const formRef = ref<FormInstance>(); const treeRef = ref<any>()
|
||||
const detailLoading = ref(false); const saving = ref(false)
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const permissionTree = ref<PermissionNode[]>([])
|
||||
const treeSearch = ref(''); const previewVisible = ref(false); const currentCheckedKeys = ref<string[]>([])
|
||||
|
||||
const presetTemplates = [
|
||||
{ value: 'property_admin', label: '物业管理员模板' }, { value: 'supervisor', label: '主管模板' },
|
||||
{ value: 'team_leader', label: '班组长模板' }, { value: 'repairman', label: '维修员模板' },
|
||||
{ value: 'inspector', label: '巡检员模板' }, { value: 'cleaner', label: '保洁员模板' },
|
||||
{ value: 'hospital_view', label: '医院查看模板' }
|
||||
]
|
||||
|
||||
const formData = reactive<RoleFormData>({ name: '', description: '', scope: 'hospital', presetTemplate: '', permissions: [] })
|
||||
let snapshot = JSON.parse(JSON.stringify(formData)); let isDirty = ref(false)
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }, { min: 2, max: 30, message: '角色名称长度2-30字符', trigger: 'blur' }],
|
||||
scope: [{ required: true, message: '请选择适用范围', trigger: 'change' }]
|
||||
}
|
||||
|
||||
watch(treeSearch, val => treeRef.value?.filter(val))
|
||||
|
||||
function handleCheck() {
|
||||
const checked = treeRef.value?.getCheckedKeys() || []
|
||||
const halfChecked = treeRef.value?.getHalfCheckedKeys() || []
|
||||
formData.permissions = [...checked, ...halfChecked]
|
||||
isDirty.value = JSON.stringify(formData) !== snapshot
|
||||
}
|
||||
|
||||
async function handleTemplateChange(val: string) {
|
||||
if (!val) return
|
||||
try {
|
||||
await ElMessageBox.confirm('选择模板将覆盖当前权限配置,是否继续?', '操作确认', { type: 'warning' })
|
||||
// Mock: auto-check some nodes based on template
|
||||
const templatePermMap: Record<string, string[]> = {
|
||||
property_admin: ['menu:repair', 'menu:inspection'], supervisor: ['menu:repair', 'menu:inspection'],
|
||||
team_leader: ['menu:repair'], repairman: ['menu:repair'], inspector: ['menu:inspection'],
|
||||
cleaner: ['menu:cleaning'], hospital_view: ['menu:repair', 'menu:contract']
|
||||
}
|
||||
const keys = templatePermMap[val] || []
|
||||
treeRef.value?.setCheckedKeys(keys)
|
||||
handleCheck()
|
||||
} catch { formData.presetTemplate = '' }
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
const checked = treeRef.value?.getCheckedKeys() || []
|
||||
currentCheckedKeys.value = checked; previewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
handleCheck()
|
||||
if (formData.permissions.length === 0) { showResultFeedback('error', '请至少分配一项权限'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) await permissionApi.updateRole(route.params.id as string, formData)
|
||||
else await permissionApi.createRole(formData)
|
||||
showResultFeedback('success', '保存成功')
|
||||
snapshot = JSON.parse(JSON.stringify(formData)); isDirty.value = false
|
||||
setTimeout(() => router.push('/permission/roles'), 300)
|
||||
} catch (e: any) { showResultFeedback('error', e.message || '保存失败') } finally { saving.value = false }
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isDirty.value) ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' }).then(() => router.push('/permission/roles')).catch(() => {})
|
||||
else router.push('/permission/roles')
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (saving.value) { next(false); return }
|
||||
if (isDirty.value) ElMessageBox.confirm('当前修改尚未保存,离开后将丢失未保存的内容,是否确认离开?', '提示', { type: 'warning' }).then(() => next()).catch(() => next(false))
|
||||
else next()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try { permissionTree.value = await permissionApi.getPermissionTree() } catch { /* */ }
|
||||
if (isEdit.value) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const detail = await permissionApi.getRoleDetail(route.params.id as string)
|
||||
Object.assign(formData, { name: detail.name, description: detail.description, scope: detail.scope, presetTemplate: '', permissions: [] })
|
||||
// Load role permissions and set checked
|
||||
const perms = await permissionApi.getRolePermissions(route.params.id as string)
|
||||
const codes = extractAllCodes(perms.length ? perms : permissionTree.value)
|
||||
setTimeout(() => treeRef.value?.setCheckedKeys(codes), 100)
|
||||
snapshot = JSON.parse(JSON.stringify(formData))
|
||||
} catch (e: any) { showResultFeedback('error', e.message || '数据加载失败') } finally { detailLoading.value = false }
|
||||
}
|
||||
})
|
||||
|
||||
function extractAllCodes(nodes: PermissionNode[]): string[] {
|
||||
const codes: string[] = []
|
||||
function walk(list: PermissionNode[]) { for (const n of list) { codes.push(n.code); if (n.children) walk(n.children) } }
|
||||
walk(nodes); return codes
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-title { font-size:16px; font-weight:600; color:var(--el-text-color-primary); border-bottom:1px solid var(--el-border-color-lighter); padding-bottom:8px; margin-bottom:16px; }
|
||||
.permission-tree-wrapper { border:1px solid var(--el-border-color); border-radius:4px; padding:12px; max-height:500px; overflow-y:auto; }
|
||||
.form-footer { text-align:right; padding-top:16px; border-top:1px solid var(--el-border-color-lighter); }
|
||||
</style>
|
||||
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="role-list">
|
||||
<Breadcrumb :items="[{ label: '权限管理' }, { label: '角色管理' }]" />
|
||||
<QueryPanel :model="queryForm" :loading="loading" @search="handleSearch" @reset="handleReset">
|
||||
<el-form-item label="角色名称"><el-input v-model="queryForm.name" clearable maxlength="30" placeholder="请输入" @keyup.enter="handleSearch" /></el-form-item>
|
||||
<el-form-item label="适用范围">
|
||||
<el-select v-model="queryForm.scope" clearable placeholder="请选择">
|
||||
<el-option label="医院账号" value="hospital" /><el-option label="物业管理员" value="property_admin" /><el-option label="物业下属" value="property_staff" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" clearable placeholder="请选择">
|
||||
<el-option label="启用" value="enabled" /><el-option label="停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</QueryPanel>
|
||||
<ActionBar>
|
||||
<el-button v-hasPermission="'permission:role:create'" type="primary" :icon="Plus" @click="$router.push('/permission/roles/create')">新增角色</el-button>
|
||||
</ActionBar>
|
||||
<DataTable :loading="loading" :data="tableData" stripe border>
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="name" label="角色名称" min-width="150" sortable />
|
||||
<el-table-column label="适用范围" width="120">
|
||||
<template #default="{ row }">{{ scopeMap[row.scope] || row.scope }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预设模板" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.isPreset ? 'primary' : 'info'" size="small">{{ row.isPreset ? '是' : '否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联账号数" width="100" sortable>
|
||||
<template #default="{ row }"><el-link type="primary" underline="never" @click="showRelatedAccounts(row)">{{ row.accountCount }}</el-link></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" sortable prop="status">
|
||||
<template #default="{ row }"><StatusTag :status="row.status" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-hasPermission="'permission:role:update'" type="primary" link size="small" :loading="row._loading" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-hasPermission="'permission:role:view'" type="primary" link size="small" @click="handlePreview(row)">权限预览</el-button>
|
||||
<el-button v-if="row.status === 'enabled'" v-hasPermission="'permission:role:update'" type="primary" link size="small" :loading="row._loading" @click="handleDisable(row)">停用</el-button>
|
||||
<el-button v-if="row.accountCount === 0" v-hasPermission="'permission:role:delete'" type="danger" link size="small" :loading="row._loading" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</DataTable>
|
||||
<Pagination :pagination="pagination" @change="handlePageChange" />
|
||||
|
||||
<!-- 关联账号弹窗 -->
|
||||
<el-dialog v-model="relatedDialogVisible" title="关联账号" width="500px">
|
||||
<el-table :data="relatedAccounts" border size="small"><el-table-column prop="username" label="登录账号" /><el-table-column prop="unit" label="绑定单位" /></el-table>
|
||||
<template #footer><el-button @click="relatedDialogVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 权限预览弹窗 -->
|
||||
<el-dialog v-model="previewDialogVisible" title="权限预览" width="600px" :close-on-click-modal="true">
|
||||
<div v-loading="previewLoading">
|
||||
<el-input v-model="previewSearch" placeholder="搜索权限名称" clearable prefix-icon="Search" style="margin-bottom: 12px" />
|
||||
<el-tree :data="previewTree" default-expand-all :filter-node-method="filterNode" ref="previewTreeRef" node-key="code" :props="{ label: 'name', children: 'children' }">
|
||||
<template #default="{ data }">
|
||||
<span>{{ data.name }}</span>
|
||||
<el-tag v-if="data.type === 'action'" :type="previewCheckedKeys.includes(data.code) ? 'success' : 'info'" size="small" style="margin-left: 8px">
|
||||
{{ previewCheckedKeys.includes(data.code) ? '✓' : '✗' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
<template #footer><el-button @click="previewDialogVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { permissionApi } from '@/api'
|
||||
import type { Role, RoleQuery, PermissionNode } from '@/api/types/permission'
|
||||
import Breadcrumb from '@/components/shared/Breadcrumb/index.vue'
|
||||
import QueryPanel from '@/components/shared/QueryPanel/index.vue'
|
||||
import ActionBar from '@/components/shared/ActionBar/index.vue'
|
||||
import DataTable from '@/components/shared/DataTable/index.vue'
|
||||
import Pagination from '@/components/shared/Pagination/index.vue'
|
||||
import StatusTag from '@/components/shared/StatusTag/index.vue'
|
||||
import { showResultFeedback } from '@/utils/result-feedback'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const tableData = ref<(Role & { _loading?: boolean })[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
const scopeMap: Record<string, string> = { hospital: '医院账号', property_admin: '物业管理员', property_staff: '物业下属' }
|
||||
|
||||
const queryForm = reactive<RoleQuery>({ page: 1, pageSize: 20, name: '', scope: '', status: '' })
|
||||
|
||||
const relatedDialogVisible = ref(false)
|
||||
const relatedAccounts = ref<{ username: string; unit: string }[]>([])
|
||||
|
||||
const previewDialogVisible = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewTree = ref<PermissionNode[]>([])
|
||||
const previewCheckedKeys = ref<string[]>([])
|
||||
const previewSearch = ref('')
|
||||
const previewTreeRef = ref<any>(null)
|
||||
|
||||
watch(previewSearch, (val) => { previewTreeRef.value?.filter(val) })
|
||||
function filterNode(value: string, data: any) { if (!value) return true; return data.name.includes(value) }
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await permissionApi.getRoleList({ ...queryForm, page: pagination.page, pageSize: pagination.pageSize })
|
||||
tableData.value = res.list; pagination.total = res.pagination.total; pagination.totalPages = res.pagination.totalPages
|
||||
} catch (e: any) { showResultFeedback('error', e.message || '加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.page = 1; loadData() }
|
||||
function handleReset() { queryForm.name = ''; queryForm.scope = ''; queryForm.status = ''; pagination.page = 1; loadData() }
|
||||
function handlePageChange(page: number, pageSize: number) { pagination.page = page; pagination.pageSize = pageSize; loadData() }
|
||||
function handleEdit(row: Role) { router.push(`/permission/roles/${row.id}/edit`) }
|
||||
|
||||
function showRelatedAccounts(row: Role) {
|
||||
// Mock related accounts
|
||||
relatedAccounts.value = Array.from({ length: Math.min(row.accountCount, 10) }, (_, i) => ({
|
||||
username: `user_${row.id}_${i + 1}`, unit: '示例单位'
|
||||
}))
|
||||
relatedDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handlePreview(row: Role) {
|
||||
previewDialogVisible.value = true; previewLoading.value = true
|
||||
try {
|
||||
const tree = await permissionApi.getPermissionTree()
|
||||
previewTree.value = tree
|
||||
const permData = await permissionApi.getRolePermissions(row.id)
|
||||
// Extract checked keys from the permission data (simplified)
|
||||
previewCheckedKeys.value = extractCodes(permData.length ? permData : tree)
|
||||
} catch (e: any) { showResultFeedback('error', e.message || '权限加载失败') } finally { previewLoading.value = false }
|
||||
}
|
||||
|
||||
function extractCodes(nodes: PermissionNode[]): string[] {
|
||||
const codes: string[] = []
|
||||
function walk(list: PermissionNode[]) { for (const n of list) { codes.push(n.code); if (n.children) walk(n.children) } }
|
||||
walk(nodes); return codes
|
||||
}
|
||||
|
||||
async function handleDisable(row: Role & { _loading?: boolean }) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要停用「${row.name}」吗?停用后关联账号将失去该角色的权限`, '操作确认', { type: 'warning' })
|
||||
row._loading = true; await permissionApi.disableRole(row.id); showResultFeedback('success', '停用成功'); loadData()
|
||||
} catch (e: any) { if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败') } finally { row._loading = false }
|
||||
}
|
||||
|
||||
async function handleDelete(row: Role & { _loading?: boolean }) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除「${row.name}」吗?此操作不可恢复`, '删除确认', { type: 'error' })
|
||||
row._loading = true; await permissionApi.deleteRole(row.id); showResultFeedback('success', '删除成功'); loadData()
|
||||
} catch (e: any) { if (e !== 'cancel') showResultFeedback('error', e.message || '操作失败') } finally { row._loading = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue