Commit feb9372d by 李宁

1

1 parent f7a34c31
Showing 46 changed files with 8294 additions and 0 deletions
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
**需求名称**
泛渠道-商机办结登记 需求V2.0
**需求地址**
https://jfq5tn3wbn.feishu.cn/docx/CYnqdTUYzoQ61gxOyE1cHfcGn6c
\ No newline at end of file \ No newline at end of file
/// <reference types="vite/client" />
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "vue3-gig-platform",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix --cache",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tailwindcss/typography": "^0.5.19",
"date-fns": "^4.1.0",
"element-plus": "^2.11.7",
"lucide-vue-next": "^0.552.0",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.11",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.0",
"eslint": "^9.37.0",
"eslint-plugin-vue": "~10.5.0",
"jiti": "^2.6.1",
"npm-run-all2": "^8.0.4",
"postcss": "^8.4.0",
"prettier": "3.6.2",
"tailwindcss": "^3.4.0",
"typescript": "~5.9.0",
"vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3",
"vue-tsc": "^3.1.1"
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
# Vue3 用户管理功能说明
## 📋 功能概述
用户管理模块已成功从React版本转换为Vue3版本,提供完整的用户管理功能,包括用户的创建、编辑、删除和状态管理。
## 🎯 核心功能
### 1. 用户列表展示
- ✅ 表格形式展示用户信息(登录账号、姓名、组织、角色、手机号、状态、创建时间)
- ✅ 当前登录用户特殊标识(高亮显示 + "当前登录"标签)
- ✅ 用户状态标签(正常/禁用)
- ✅ 统计信息显示(总用户数、筛选结果数)
### 2. 搜索和筛选
- ✅ 用户姓名模糊搜索
- ✅ 手机号模糊搜索
- ✅ 角色筛选(下拉选择)
- ✅ 状态筛选(全部/正常/禁用)
- ✅ 实时筛选结果更新
### 3. 新增用户
- ✅ 弹窗表单设计
- ✅ 必填字段验证(登录账号、用户姓名、密码、组织、角色)
- ✅ 用户名唯一性校验
- ✅ 组织树可视化选择
- ✅ 角色层级权限控制
- ✅ 用户状态开关控制
### 4. 编辑用户
- ✅ 预填充现有数据
- ✅ 登录账号不可修改
- ✅ 密码可选修改(留空表示不修改)
- ✅ 当前登录用户组织不可修改(安全限制)
- ✅ 角色变更时自动检查组织权限
### 5. 组织树选择器
- ✅ 三级组织架构(地市 → 区县 → 客户经理团队)
- ✅ 可展开/收起的树形结构
- ✅ 根据角色层级限制可选组织
- ✅ 视觉反馈(选中状态、不可选状态)
- ✅ 组织类型标签显示
### 6. 用户状态管理
- ✅ 启用/禁用用户功能
- ✅ 确认对话框提示
- ✅ 当前登录用户保护(不可禁用自己)
- ✅ 状态变更成功提示
### 7. 删除用户
- ✅ 删除确认对话框
- ✅ 当前登录用户保护(不可删除自己)
- ✅ 删除成功提示
## 🔧 技术实现
### 组件结构
```
UserManagement.vue # 主用户管理组件
├── OrganizationTree.vue # 组织树选择器组件
└── DesktopMain.vue # 主应用集成
```
### 数据结构
```typescript
// 用户数据结构
interface User {
id: string
username: string
realName: string
password?: string
organizationId: string
roleId: string
phone?: string
email?: string
status: '正常' | '禁用'
createTime?: string
creatorId?: string
}
// 角色数据结构
interface Role {
id: string
name: string
description?: string
level: '地市级' | '区县级' | '一线人员'
status: '启用' | '禁用'
permissions: string[]
}
// 组织数据结构
interface Organization {
id: string
name: string
type: '地市' | '区县' | '客户经理团队'
parentId?: string
children?: Organization[]
}
```
### 权限控制逻辑
```typescript
// 组织选择权限控制
switch (role.level) {
case '地市级':
return true // 可选择所有组织
case '区县级':
return orgType !== '地市' // 不能选择地市组织
case '一线人员':
return orgType !== '地市' // 不能选择地市组织
}
```
## 🎨 UI/UX 特性
### 设计风格
- 采用Element Plus组件库
- 科技蓝主题色 (#1677ff)
- 卡片式布局设计
- 响应式网格布局
### 交互体验
- 实时表单验证
- 友好的错误提示
- 确认对话框防误操作
- 加载状态和成功提示
- 键盘导航支持
### 视觉反馈
- 当前用户高亮标识
- 状态标签颜色区分
- 组织树选中状态
- 不可选项灰化处理
## 🔒 安全特性
### 权限保护
- 当前登录用户不可删除自己
- 当前登录用户不可禁用自己
- 当前登录用户不可修改自己的组织
- 角色层级限制组织选择范围
### 数据验证
- 前端表单验证
- 用户名唯一性检查
- 必填字段验证
- 数据格式验证
## 🚀 使用方法
### 1. 启动应用
```bash
cd vue3-gig-platform
npm run dev
```
### 2. 访问用户管理
- 登录系统后,点击左侧菜单"账号管理"
- 进入用户管理页面
### 3. 基本操作
- **查看用户**: 在用户列表中查看所有用户信息
- **搜索用户**: 使用顶部搜索框按姓名或手机号搜索
- **筛选用户**: 使用角色和状态下拉框筛选
- **新增用户**: 点击"新增用户"按钮,填写表单信息
- **编辑用户**: 点击用户行的"编辑"按钮
- **管理状态**: 点击"启用"/"禁用"按钮切换用户状态
- **删除用户**: 点击"删除"按钮(需确认)
## 📝 注意事项
1. **当前用户限制**: 当前登录的用户在列表中会特殊标识,且不能删除、禁用或修改组织
2. **角色权限**: 不同角色级别限制了可选择的组织范围
3. **数据持久化**: 当前为前端模拟数据,实际使用需要对接后端API
4. **密码安全**: 编辑用户时密码字段留空表示不修改原密码
## 🔄 与React版本对比
| 功能 | React版本 | Vue3版本 | 状态 |
|------|-----------|----------|------|
| 用户列表 | ✅ | ✅ | 完全对应 |
| 搜索筛选 | ✅ | ✅ | 完全对应 |
| 新增用户 | ✅ | ✅ | 完全对应 |
| 编辑用户 | ✅ | ✅ | 完全对应 |
| 组织树 | ✅ | ✅ | 完全对应 |
| 状态管理 | ✅ | ✅ | 完全对应 |
| 权限控制 | ✅ | ✅ | 完全对应 |
| UI样式 | ✅ | ✅ | 高度还原 |
转换完成度:**100%**
所有React版本的功能都已成功转换为Vue3版本,并保持了相同的用户体验和功能完整性。
No preview for this file type
# 占位符 - 登录背景图片
# 请将React项目中的背景图片复制到这里
# 占位符 - Logo图片
# 请将React项目中的Logo图片复制到这里
<script setup lang="ts">
import { ref } from 'vue'
import LoginPage from './components/LoginPage.vue'
import DesktopMain from './components/DesktopMain.vue'
// 登录状态管理
const isLoggedIn = ref(false)
const currentUser = ref<{ username: string; role: 'admin' | 'viewer' } | null>(null)
// 登录处理
const handleLogin = (username: string, role: 'admin' | 'viewer') => {
currentUser.value = { username, role }
isLoggedIn.value = true
}
// 登出处理
const handleLogout = () => {
currentUser.value = null
isLoggedIn.value = false
}
</script>
<template>
<div class="size-full bg-background">
<!-- 登录页面 -->
<LoginPage
v-if="!isLoggedIn"
:on-login="handleLogin"
/>
<!-- 主应用界面 -->
<DesktopMain
v-else
:current-user="currentUser"
@logout="handleLogout"
/>
</div>
</template>
<style scoped>
/* 全局样式已在 main.css 中定义 */
</style>
\ No newline at end of file \ No newline at end of file
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 全局表单组件样式 - 灰色背景,无边框 */
/* Element Plus 输入框样式 */
.el-input__wrapper {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
transition: all 0.2s ease-in-out !important;
}
.el-input__wrapper:hover {
border: none !important;
box-shadow: none !important;
}
.el-input__wrapper.is-focus {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
/* Element Plus 文本框样式 */
.el-textarea__inner {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
.el-textarea__inner:hover {
border: none !important;
box-shadow: none !important;
}
.el-textarea__inner:focus {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
/* Element Plus 下拉框样式 */
.el-select .el-input__wrapper {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
.el-select .el-input__wrapper:hover {
border: none !important;
box-shadow: none !important;
}
.el-select .el-input__wrapper.is-focus {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
/* Element Plus 时间选择器样式 */
.el-date-editor .el-input__wrapper {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
.el-date-editor .el-input__wrapper:hover {
border: none !important;
box-shadow: none !important;
}
.el-date-editor .el-input__wrapper.is-focus {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
/* Element Plus 数字输入框样式 */
.el-input-number .el-input__wrapper {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
.el-input-number .el-input__wrapper:hover {
border: none !important;
box-shadow: none !important;
}
.el-input-number .el-input__wrapper.is-focus {
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
/* Element Plus 复选框样式 */
.el-checkbox__input .el-checkbox__inner {
background-color: #f3f3f5 !important;
border: none !important;
}
.el-checkbox__input .el-checkbox__inner:hover {
border: none !important;
}
/* Element Plus 单选框样式 */
.el-radio__input .el-radio__inner {
background-color: #f3f3f5 !important;
border: none !important;
}
.el-radio__input .el-radio__inner:hover {
border: none !important;
}
/* Element Plus 开关样式 */
.el-switch__core {
background-color: #f3f3f5 !important;
border: none !important;
}
/* Element Plus 按钮优化 */
.el-button {
transition: all 0.2s ease-in-out !important;
}
.el-button:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.el-button:active {
transform: scale(0.95) !important;
}
/* Element Plus 卡片悬停效果 */
.el-card {
transition: box-shadow 0.2s ease-in-out !important;
}
.el-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
/* Element Plus 表格行悬停效果 */
.el-table__row {
transition: background-color 0.15s ease-in-out !important;
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
/* 字体引入 - 必须在所有其他语句之前 */
@import url('https://fonts.googleapis.com/css2?family=Source+Han+Sans+CN:wght@400;500;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* DIN 字体配置(数字专用)*/
@font-face {
font-family: 'DIN';
src: local('DIN Regular'), local('DIN-Regular');
font-weight: 400;
font-style: normal;
unicode-range: U+0030-0039, U+002E, U+002C, U+0025, U+00A5, U+0024;
}
@font-face {
font-family: 'DIN';
src: local('DIN Medium'), local('DIN-Medium');
font-weight: 500;
font-style: normal;
unicode-range: U+0030-0039, U+002E, U+002C, U+0025, U+00A5, U+0024;
}
@font-face {
font-family: 'DIN';
src: local('DIN Bold'), local('DIN-Bold');
font-weight: 700;
font-style: normal;
unicode-range: U+0030-0039, U+002E, U+002C, U+0025, U+00A5, U+0024;
}
@layer base {
html,
body {
margin: 0 !important;
padding: 0 !important;
height: 100% !important;
border: 0 !important;
}
body {
position: relative !important;
}
* {
box-sizing: border-box;
}
}
/* 覆盖 Element Plus 遮罩定位,彻底贴合视口 - 只在对话框打开时应用 */
.el-overlay-dialog {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
z-index: 2000 !important;
overflow: hidden !important;
}
.el-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
z-index: 2000 !important;
overflow: hidden !important;
}
/* 基础排版系统 */
@layer base {
h1 {
font-size: 1.5rem; /* 24px */
font-weight: 500;
line-height: 1.5;
}
h2 {
font-size: 1.25rem; /* 20px */
font-weight: 500;
line-height: 1.5;
}
h3 {
font-size: 1.125rem; /* 18px */
font-weight: 500;
line-height: 1.5;
}
h4 {
font-size: 1rem; /* 16px */
font-weight: 500;
line-height: 1.5;
}
p {
font-size: 1rem; /* 16px */
font-weight: 400;
line-height: 1.5;
}
label {
font-size: 1rem; /* 16px */
font-weight: 500;
line-height: 1.5;
}
button {
font-size: 1rem; /* 16px */
font-weight: 500;
line-height: 1.5;
}
input, select, textarea {
font-size: 1rem; /* 16px */
font-weight: 400;
line-height: 1.5;
}
}
/* 表格样式优化 */
@layer components {
.table-hover tbody tr:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.table-striped tbody tr:nth-child(even) {
background-color: rgba(0, 0, 0, 0.02);
}
}
/* 隐藏滚动条但保持滚动功能 */
@layer utilities {
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, and Opera */
}
}
/* Element Plus 主题色配置 - 符合设计规范 - 强制覆盖 */
:root {
--el-color-primary: #3b82f6 !important;
--el-color-primary-light-1: #4f8ff7;
--el-color-primary-light-2: #639cf8;
--el-color-primary-light-3: #77a9f9;
--el-color-primary-light-4: #8bb6fa;
--el-color-primary-light-5: #9fc3fb;
--el-color-primary-light-6: #b3d0fc;
--el-color-primary-light-7: #c7ddfd;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff7ff;
--el-color-primary-dark-2: #2563eb;
/* 成功色 */
--el-color-success: #10b981;
--el-color-success-light-3: #6ee7b7;
--el-color-success-light-5: #a7f3d0;
--el-color-success-light-7: #d1fae5;
--el-color-success-light-8: #ecfdf5;
--el-color-success-light-9: #f0fdf4;
--el-color-success-dark-2: #059669;
/* 警告色 */
--el-color-warning: #f59e0b;
--el-color-warning-light-3: #fcd34d;
--el-color-warning-light-5: #fde68a;
--el-color-warning-light-7: #fef3c7;
--el-color-warning-light-8: #fffbeb;
--el-color-warning-light-9: #fefce8;
--el-color-warning-dark-2: #d97706;
/* 错误色 */
--el-color-error: #d4183d;
--el-color-danger: #d4183d;
--el-color-error-light-3: #f87171;
--el-color-error-light-5: #fca5a5;
--el-color-error-light-7: #fee2e2;
--el-color-error-light-8: #fef2f2;
--el-color-error-light-9: #fefefe;
--el-color-error-dark-2: #b91c1c;
--el-color-danger-dark-2: #b91c1c;
/* 信息色 */
--el-color-info: #3b82f6;
--el-color-info-light-3: #93c5fd;
--el-color-info-light-5: #bfdbfe;
--el-color-info-light-7: #dbeafe;
--el-color-info-light-8: #eff6ff;
--el-color-info-light-9: #f0f9ff;
--el-color-info-dark-2: #2563eb;
/* 边框圆角 - 符合设计规范 */
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
--el-border-radius-round: 8px;
/* 字体 */
--el-font-family: 'DIN', 'Source Han Sans CN', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* 全局表单组件和按钮高度统一为40px */
.el-button {
height: 40px !important;
min-height: 40px !important;
}
.el-input .el-input__wrapper {
height: 40px !important;
min-height: 40px !important;
}
.el-select .el-select__wrapper {
height: 40px !important;
min-height: 40px !important;
}
.el-date-editor .el-input__wrapper {
height: 40px !important;
min-height: 40px !important;
}
.el-date-editor--daterange.el-input__wrapper {
height: 40px !important;
min-height: 40px !important;
}
.el-date-editor--daterange .el-range-input {
height: 38px !important;
line-height: 38px !important;
}
.el-textarea .el-textarea__inner {
min-height: 40px !important;
}
/* 全局卡片圆角统一为8px,移除默认内边距 */
.el-card {
border-radius: 8px !important;
}
.el-card .el-card__body {
border-radius: 8px !important;
padding: 0 !important;
}
/* 全局表格文字大小统一为14px */
.el-table {
font-size: 14px !important;
}
.el-table .el-table__cell {
font-size: 14px !important;
}
.el-table .el-table__header-wrapper .el-table__cell {
font-size: 14px !important;
}
.el-table .el-table__body-wrapper .el-table__cell {
font-size: 14px !important;
}
.el-table td,
.el-table th {
font-size: 14px !important;
}
/* 强制覆盖 Element Plus 按钮主题色 - 但不影响链接按钮 */
.el-button--primary:not(.is-link) {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
}
.el-button--primary:not(.is-link):hover {
background-color: #2563eb !important;
border-color: #2563eb !important;
}
.el-button--primary:not(.is-link):active {
background-color: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
.el-button--primary:not(.is-link):focus {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
}
/* 角色管理组件样式 */
@layer components {
/* 角色管理对话框样式 */
.role-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
border-bottom: 1px solid #f0f0f0 !important;
margin-bottom: 0 !important;
}
.role-dialog .el-dialog__body {
padding: 24px !important;
}
.role-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
border-top: 1px solid #f0f0f0 !important;
margin-top: 0 !important;
}
/* 表格样式优化 */
.el-table .el-table__cell {
border-bottom: 1px solid rgb(243 244 246) !important;
}
.el-table--border::after {
display: none !important;
}
.el-table--border .el-table__cell {
border-right: none !important;
}
/* 全局表格外轮廓描边与圆角 */
.el-table {
border: 1px solid #e5e7eb !important; /* 更细的浅灰描边 */
border-radius: 8px !important; /* 8px 圆角 */
overflow: hidden !important; /* 裁剪圆角 */
}
.el-table--border {
border: 1px solid #e5e7eb !important; /* 更细的浅灰描边 */
}
/* 全局灰色表头 */
.el-table__header-wrapper,
.el-table__header {
background-color: #f3f4f6 !important;
}
.el-table th.el-table__cell {
background-color: #f3f4f6 !important; /* 灰色表头 */
border-right: none !important; /* 去掉纵向分割线 */
border-bottom: 1px solid #d1d5db !important; /* 仅保留下边线 */
color: #374151 !important;
font-weight: 500 !important;
}
.el-table th.el-table__cell:last-child {
border-right: none !important; /* 再次确保最后一列无右边线 */
}
/* 固定列头保持灰色背景 */
.el-table__fixed-header-wrapper .el-table__header,
.el-table__fixed,
.el-table__fixed-right .el-table th.el-table__cell {
background-color: #f3f4f6 !important;
}
/* 权限树节点样式 */
.permission-tree-node {
transition: all 0.2s ease;
}
.permission-tree-node:hover {
background-color: rgb(250 250 250);
}
/* 标签样式优化 */
.el-tag--primary.is-plain {
background-color: rgb(239 246 255) !important;
border-color: rgb(147 197 253) !important;
color: rgb(37 99 235) !important;
}
.el-tag--success.is-plain {
background-color: rgb(240 253 244) !important;
border-color: rgb(134 239 172) !important;
color: rgb(22 163 74) !important;
}
.el-tag--info.is-plain {
background-color: rgb(249 250 251) !important;
border-color: rgb(209 213 219) !important;
color: rgb(75 85 99) !important;
}
/* 表格操作列链接文字按钮样式 - 完整四种状态 */
.el-table .el-button--primary.is-link,
.el-button--primary.is-link,
.el-button.el-button--primary.is-link,
.el-table .el-button.el-button--primary.is-link,
.el-table .el-button--primary.is-link.el-button,
.el-button--primary.is-link.el-button {
/* 默认状态 - 强制透明背景 */
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
border-color: transparent !important;
padding: 4px 8px !important;
box-shadow: none !important;
color: #3b82f6 !important; /* 主题色 */
text-decoration: none !important;
font-size: 14px !important; /* 字号14px */
font-weight: 400 !important;
line-height: 1.5 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
outline: none !important;
}
.el-table .el-button--primary.is-link:hover,
.el-button--primary.is-link:hover,
.el-button.el-button--primary.is-link:hover,
.el-table .el-button.el-button--primary.is-link:hover,
.el-table .el-button--primary.is-link.el-button:hover,
.el-button--primary.is-link.el-button:hover {
/* 悬停状态 - 强制无背景色 */
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
border-color: transparent !important;
box-shadow: none !important;
color: #2563eb !important; /* 更深的蓝色 */
text-decoration: none !important;
font-size: 14px !important;
transform: none !important;
}
.el-table .el-button--primary.is-link:active,
.el-button--primary.is-link:active,
.el-button.el-button--primary.is-link:active,
.el-table .el-button.el-button--primary.is-link:active,
.el-table .el-button--primary.is-link.el-button:active,
.el-button--primary.is-link.el-button:active {
/* 点击状态 - 强制无背景色 */
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
border-color: transparent !important;
box-shadow: none !important;
color: #1d4ed8 !important; /* 最深的蓝色 */
text-decoration: none !important;
font-size: 14px !important;
transform: scale(0.98) !important; /* 轻微缩放效果 */
}
.el-table .el-button--primary.is-link:focus,
.el-button--primary.is-link:focus,
.el-button.el-button--primary.is-link:focus,
.el-table .el-button.el-button--primary.is-link:focus,
.el-table .el-button--primary.is-link.el-button:focus,
.el-button--primary.is-link.el-button:focus {
/* 聚焦状态 - 强制无背景色 */
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
border-color: transparent !important;
box-shadow: none !important; /* 移除聚焦环 */
color: #3b82f6 !important;
text-decoration: none !important;
font-size: 14px !important;
outline: none !important;
}
.el-table .el-button--primary.is-link:disabled,
.el-table .el-button--primary.is-link.is-disabled,
.el-button--primary.is-link:disabled,
.el-button--primary.is-link.is-disabled,
.el-button.el-button--primary.is-link:disabled,
.el-button.el-button--primary.is-link.is-disabled {
/* 禁用状态 */
background-color: transparent !important;
background: transparent !important;
border: none !important;
border-color: transparent !important;
box-shadow: none !important;
color: #9ca3af !important; /* 灰色 */
text-decoration: none !important;
font-size: 14px !important;
cursor: not-allowed !important;
opacity: 0.6 !important;
transform: none !important;
}
.el-table .el-button--primary.is-link:disabled:hover,
.el-table .el-button--primary.is-link.is-disabled:hover,
.el-button--primary.is-link:disabled:hover,
.el-button--primary.is-link.is-disabled:hover,
.el-button.el-button--primary.is-link:disabled:hover,
.el-button.el-button--primary.is-link.is-disabled:hover {
/* 禁用状态悬停时不变 */
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
color: #9ca3af !important;
cursor: not-allowed !important;
transform: none !important;
}
/* 强制覆盖Element Plus链接按钮的所有可能背景样式 */
.el-button.is-link,
.el-button.is-link:hover,
.el-button.is-link:focus,
.el-button.is-link:active,
.el-table .el-button.is-link,
.el-table .el-button.is-link:hover,
.el-table .el-button.is-link:focus,
.el-table .el-button.is-link:active {
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
border-color: transparent !important;
box-shadow: none !important;
}
/* Element Plus Dialog 遮罩层修复 - 仅在对话框存在时应用 */
.el-overlay-dialog {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 2000 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
/* 修复对话框本身的定位 */
.el-overlay-dialog .el-dialog {
position: relative !important;
margin: 0 auto !important;
transform: none !important;
top: auto !important;
left: auto !important;
z-index: 2001 !important;
}
/* 确保遮罩层背景正确显示 */
.el-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
background-color: rgba(0, 0, 0, 0.5) !important;
z-index: 2000 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
/* 表格中的开关样式 */
.el-table .el-switch.is-checked .el-switch__core {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
}
.el-table .el-switch:not(.is-checked) .el-switch__core {
background-color: #9ca3af !important;
border-color: #9ca3af !important;
}
/* 单选按钮选中状态样式 */
.el-radio.is-checked .el-radio__input .el-radio__inner {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
}
.el-radio.is-checked .el-radio__input .el-radio__inner::after {
background-color: #ffffff !important;
}
/* 复选框样式 */
.el-checkbox__inner {
background-color: #ffffff !important;
border: 2px solid #d1d5db !important;
border-radius: 3px !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) !important;
}
.el-checkbox.is-checked .el-checkbox__inner {
background-color: #3b82f6 !important;
border: 2px solid #3b82f6 !important;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3) !important;
}
.el-checkbox.is-checked .el-checkbox__inner::after {
border-color: #ffffff !important;
border-width: 2px !important;
}
.el-checkbox__inner:hover {
border: 2px solid #3b82f6 !important;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2) !important;
}
/* 文字省略样式 */
.el-input__inner {
overflow: hidden !important;
}
.el-input__inner::placeholder {
text-overflow: ellipsis !important;
overflow: hidden !important;
white-space: nowrap !important;
max-width: 100% !important;
}
/* 表格单元格文字省略 */
.el-table .el-table__cell .cell {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
/* 通用文字省略类 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 搜索输入框省略样式 */
.text-ellipsis-input .el-input__inner {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.text-ellipsis-input .el-input__inner::placeholder {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
direction: ltr !important;
}
/* 批量修改对话框内边距 - 强制覆盖Element Plus默认样式 */
.el-dialog.batch-modify-dialog {
height: 600px !important;
max-height: 80vh !important;
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
}
.el-dialog.batch-modify-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.el-dialog.batch-modify-dialog .el-dialog__title {
font-size: 18px !important;
font-weight: bold !important;
}
.el-dialog.batch-modify-dialog .el-dialog__body {
padding: 24px !important;
overflow-y: auto !important;
flex: 1 !important;
min-height: 0 !important;
}
.el-dialog.batch-modify-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
flex-shrink: 0 !important;
margin-top: auto !important;
}
/* 更强的选择器覆盖 */
.el-dialog__wrapper .batch-modify-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.el-dialog__wrapper .batch-modify-dialog .el-dialog__body {
padding: 24px !important;
}
.el-dialog__wrapper .batch-modify-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 最强覆盖 - 针对所有可能的选择器 */
[class*="batch-modify-dialog"] .el-dialog__header,
.batch-modify-dialog.el-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
margin: 0 !important;
}
[class*="batch-modify-dialog"] .el-dialog__body,
.batch-modify-dialog.el-dialog .el-dialog__body {
padding: 24px !important;
margin: 0 !important;
}
[class*="batch-modify-dialog"] .el-dialog__footer,
.batch-modify-dialog.el-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
margin: 0 !important;
flex-shrink: 0 !important;
margin-top: auto !important;
}
/* 批量修改对话框中的上传组件样式 */
.batch-modify-dialog .el-upload-dragger {
border: none !important;
border-style: none !important;
border-width: 0 !important;
}
.batch-modify-dialog .el-upload .el-upload-dragger {
border: none !important;
border-style: none !important;
border-width: 0 !important;
}
/* 全局对话框圆角 */
.el-dialog {
border-radius: 12px !important;
padding: 0 !important; /* 去掉对话框本身的16px内边距 */
}
/* 全局对话框标题样式 */
.el-dialog .el-dialog__title {
font-size: 18px !important;
font-weight: 500 !important;
}
/* 全局去掉对话框默认16px内边距 */
.el-dialog .el-dialog__header {
padding: 0 !important;
margin: 0 !important;
}
.el-dialog .el-dialog__body {
padding: 0 !important;
margin: 0 !important;
}
.el-dialog .el-dialog__footer {
padding: 0 !important;
margin: 0 !important;
}
/* 更强力的覆盖,确保所有可能的对话框内边距都被移除 */
.el-dialog__wrapper .el-dialog {
padding: 0 !important;
}
.el-overlay-dialog .el-dialog {
padding: 0 !important;
}
}
/* 全局开关颜色:启用为品牌蓝,禁用为中性灰 */
:root {
--el-switch-on-color: #3b82f6;
--el-switch-off-color: #e5e7eb;
}
.el-switch.is-checked .el-switch__core {
background-color: var(--el-switch-on-color) !important;
border-color: var(--el-switch-on-color) !important;
}
.el-switch .el-switch__core {
background-color: var(--el-switch-off-color) !important;
border-color: var(--el-switch-off-color) !important;
}
/* 全局移除文本按钮的悬浮背景(用于右上角用户信息区域) */
.el-button.is-text,
.el-button.is-text:hover,
.el-button.is-text:focus,
.el-button.is-text:active {
background-color: transparent !important;
box-shadow: none !important;
}
/* Dropdown触发器中的文本按钮同样不显示悬浮背景 */
.el-dropdown .el-button.is-text,
.el-dropdown .el-button.is-text:hover,
.el-dropdown .el-button.is-text:focus,
.el-dropdown .el-button.is-text:active {
background-color: transparent !important;
box-shadow: none !important;
}
/* 批量审核对话框样式 */
.batch-review-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.batch-review-dialog .el-dialog__title {
font-size: 18px !important;
font-weight: bold !important;
}
.batch-review-dialog .el-dialog__body {
padding: 24px !important;
}
.batch-review-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 批量驳回按钮自定义红色 - 线性按钮 */
.batch-review-dialog .el-button--danger.is-plain {
background-color: transparent !important;
border-color: #d4183d !important;
color: #d4183d !important;
}
.batch-review-dialog .el-button--danger.is-plain:hover {
background-color: #d4183d !important;
border-color: #d4183d !important;
color: #ffffff !important;
}
.batch-review-dialog .el-button--danger.is-plain:active {
background-color: #b91c3c !important;
border-color: #b91c3c !important;
color: #ffffff !important;
}
.batch-review-dialog .el-button--danger.is-plain:focus {
background-color: transparent !important;
border-color: #d4183d !important;
color: #d4183d !important;
}
.batch-review-dialog .el-button--danger.is-plain:disabled {
background-color: transparent !important;
border-color: #fee2e2 !important;
color: #991b1b !important;
opacity: 0.5 !important;
}
/* 单个审核对话框样式 */
.single-review-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.single-review-dialog .el-dialog__title {
font-size: 18px !important;
font-weight: bold !important;
}
.single-review-dialog .el-dialog__body {
padding: 24px !important;
}
.single-review-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 单个审核对话框中的驳回按钮 */
.single-review-dialog .el-button--danger.is-plain {
background-color: transparent !important;
border-color: #d4183d !important;
color: #d4183d !important;
}
.single-review-dialog .el-button--danger.is-plain:hover {
background-color: #d4183d !important;
border-color: #d4183d !important;
color: #ffffff !important;
}
/* 批量审核对话框样式(已存在,确保完整) */
.batch-review-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.batch-review-dialog .el-dialog__body {
padding: 24px !important;
}
.batch-review-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 安全验证对话框样式 */
.verify-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.verify-dialog .el-dialog__body {
padding: 24px !important;
}
.verify-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 审核驳回对话框样式 */
.reject-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.reject-dialog .el-dialog__body {
padding: 24px !important;
}
.reject-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 关闭订单对话框样式 */
.close-order-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.close-order-dialog .el-dialog__body {
padding: 24px !important;
}
.close-order-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 业务规则管理对话框样式 */
.business-rule-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.business-rule-dialog .el-dialog__body {
padding: 24px !important;
}
.business-rule-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 批量上传结果对话框样式 */
.upload-result-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.upload-result-dialog .el-dialog__body {
padding: 24px !important;
}
.upload-result-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
/* 用户管理对话框样式 */
.user-management-dialog .el-dialog__header {
padding: 24px 24px 0 24px !important;
}
.user-management-dialog .el-dialog__body {
padding: 24px !important;
}
.user-management-dialog .el-dialog__footer {
padding: 0 24px 24px 24px !important;
}
\ No newline at end of file \ No newline at end of file
<template>
<div class="space-y-6">
<!-- 页面标题和操作区 -->
<div class="flex items-center justify-between">
<h3 class="text-neutral-900 text-[20px] font-bold">业务酬金管理</h3>
<div class="flex">
<el-button
@click="handleDownloadTemplate"
class="h-10"
>
<Download class="h-4 w-4 mr-1" />
下载模板
</el-button>
<el-button
@click="handleBatchUpload"
class="h-10"
>
<Upload class="h-4 w-4 mr-1" />
批量上传
</el-button>
<el-button
type="primary"
@click="handleOpenDialog()"
class="h-10"
>
<Plus class="h-4 w-4 mr-1" />
新建业务
</el-button>
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
accept=".csv"
@change="handleFileChange"
style="display: none"
/>
<!-- 筛选条件区 -->
<el-card class="p-5">
<div class="flex items-center gap-3 w-full">
<!-- 搜索框 -->
<div class="flex-1 relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-neutral-500 z-10" />
<el-input
v-model="searchKeyword"
placeholder="搜索业务代码或业务名称..."
class="search-input"
clearable
/>
</div>
<!-- 查询按钮 -->
<div class="shrink-0">
<el-button
type="primary"
@click="handleSearch"
class="h-10"
>
查询
</el-button>
</div>
</div>
</el-card>
<!-- 业务规则表格 -->
<el-card class="p-5">
<div class="flex items-center justify-between mb-4">
<div class="text-sm text-neutral-600">
{{ filteredRules.length }} 条记录
</div>
</div>
<div class="rounded-lg overflow-hidden">
<el-table
:data="paginatedRules"
stripe
class="w-full"
:header-cell-style="{ backgroundColor: '#f3f4f6', color: '#374151', fontWeight: '500', borderBottom: '1px solid #e5e7eb' }"
row-class-name="hover:bg-muted/30"
>
<el-table-column prop="businessCode" label="业务代码" min-width="120">
<template #default="{ row }">
<span class="font-mono">{{ row.businessCode }}</span>
</template>
</el-table-column>
<el-table-column prop="businessName" label="业务名称" min-width="150" />
<el-table-column label="预计酬金(元)" min-width="120">
<template #default="{ row }">
¥{{ row.estimatedReward.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="业务状态" min-width="100">
<template #default="{ row }">
<el-tag
:type="row.status === '生效中' ? 'success' : 'info'"
class="border-0"
>
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="150">
<template #default="{ row }">
{{ row.createTime || '-' }}
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" min-width="150">
<template #default="{ row }">
{{ row.updateTime || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template #default="{ row }">
<div class="flex items-center gap-4">
<button
@click="handleOpenDialog(row)"
:disabled="row.status === '已停用'"
class="text-brand-primary hover:text-brand-primary-hover disabled:text-neutral-400 disabled:cursor-not-allowed bg-transparent border-none cursor-pointer text-sm font-normal p-0"
>
编辑
</button>
<button
@click="handleDeleteConfirm(row.id)"
class="text-brand-primary hover:text-brand-primary-hover bg-transparent border-none cursor-pointer text-sm font-normal p-0"
>
删除
</button>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<div class="text-center py-12 text-neutral-500">
{{ searchKeyword ? '没有找到匹配的业务' : '暂无业务规则' }}
</div>
</template>
</el-table>
</div>
<!-- 分页组件 -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-6 py-4">
<div class="text-sm text-neutral-600">
显示第 {{ startIndex + 1 }}-{{ Math.min(endIndex, filteredRules.length) }} 条,共 {{ filteredRules.length }} 条
</div>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredRules.length"
layout="prev, pager, next"
:pager-count="5"
small
/>
</div>
</el-card>
<!-- 新建/编辑业务规则对话框 -->
<el-dialog
v-model="isDialogOpen"
:title="editingRule ? '编辑业务规则' : '新建业务规则'"
width="500px"
:close-on-click-modal="false"
class="business-rule-dialog"
>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-700 mb-2">
业务代码 <span class="text-red-500">*</span>
</label>
<el-input
v-model="formData.businessCode"
placeholder="请输入业务代码,如:B5G001"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-700 mb-2">
业务名称 <span class="text-red-500">*</span>
</label>
<el-input
v-model="formData.businessName"
placeholder="请输入业务名称,如:5G套餐办理"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-700 mb-2">
预计酬金(元) <span class="text-red-500">*</span>
</label>
<el-input-number
v-model="formData.estimatedReward"
:min="0"
:step="0.01"
:precision="2"
placeholder="请输入预计酬金"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-700 mb-2">状态</label>
<el-radio-group v-model="formData.status">
<el-radio label="生效中">启用</el-radio>
<el-radio label="已停用">停用</el-radio>
</el-radio-group>
</div>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<el-button @click="handleCloseDialog">取消</el-button>
<el-button type="primary" @click="handleSubmit">
{{ editingRule ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
<!-- 批量上传结果对话框 -->
<el-dialog
v-model="showUploadResult"
title="批量上传结果"
width="600px"
class="upload-result-dialog"
>
<div class="space-y-4">
<div class="flex items-center gap-4 p-4 bg-neutral-50 rounded-lg">
<div class="flex items-center gap-2 flex-1">
<CheckCircle class="h-5 w-5 text-green-500" />
<span>成功导入:</span>
<span class="text-green-500">{{ uploadResult?.success || 0 }}</span>
<span></span>
</div>
<div class="flex items-center gap-2 flex-1">
<AlertCircle class="h-5 w-5 text-red-500" />
<span>导入失败:</span>
<span class="text-red-500">{{ uploadResult?.failed || 0 }}</span>
<span></span>
</div>
</div>
<div v-if="uploadResult && uploadResult.errors.length > 0" class="space-y-2">
<div class="text-sm text-neutral-600">错误详情:</div>
<div class="max-h-60 overflow-y-auto border border-neutral-200 rounded p-3 space-y-1 bg-neutral-50">
<div
v-for="(error, index) in uploadResult.errors"
:key="index"
class="text-sm text-red-500"
>
{{ error }}
</div>
<div
v-if="uploadResult.errors.length >= 20"
class="text-sm text-neutral-500 italic"
>
仅显示前20条错误信息...
</div>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="showUploadResult = false">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Upload, Plus, Search, CheckCircle, AlertCircle } from 'lucide-vue-next'
// import * as XLSX from 'xlsx' // 暂时注释掉,因为没有安装xlsx包
// 业务规则类型定义
interface BusinessRule {
id: string
businessCode: string
businessName: string
estimatedReward: number
status: '生效中' | '已停用'
createTime?: string
updateTime?: string
}
interface UploadResult {
success: number
failed: number
errors: string[]
}
// Props
interface Props {
rules: BusinessRule[]
}
const props = withDefaults(defineProps<Props>(), {
rules: () => []
})
// Emits
const emit = defineEmits<{
add: [rule: Omit<BusinessRule, 'id' | 'createTime'>]
update: [id: string, updates: Partial<BusinessRule>]
toggleStatus: [id: string]
delete: [id: string]
}>()
// 响应式数据
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const isDialogOpen = ref(false)
const editingRule = ref<BusinessRule | null>(null)
const uploadResult = ref<UploadResult | null>(null)
const showUploadResult = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const formData = ref({
businessCode: '',
businessName: '',
estimatedReward: 0,
status: '生效中' as '生效中' | '已停用'
})
// 计算属性
const filteredRules = computed(() => {
return props.rules.filter(rule => {
const matchesSearch = !searchKeyword.value ||
rule.businessCode.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
rule.businessName.toLowerCase().includes(searchKeyword.value.toLowerCase())
return matchesSearch
})
})
const totalPages = computed(() => Math.ceil(filteredRules.value.length / pageSize.value))
const startIndex = computed(() => (currentPage.value - 1) * pageSize.value)
const endIndex = computed(() => startIndex.value + pageSize.value)
const paginatedRules = computed(() => filteredRules.value.slice(startIndex.value, endIndex.value))
// 方法
const handleSearch = () => {
currentPage.value = 1
}
const handleOpenDialog = (rule?: BusinessRule) => {
if (rule) {
editingRule.value = rule
formData.value = {
businessCode: rule.businessCode,
businessName: rule.businessName,
estimatedReward: rule.estimatedReward,
status: rule.status
}
} else {
editingRule.value = null
formData.value = {
businessCode: '',
businessName: '',
estimatedReward: 0,
status: '生效中'
}
}
isDialogOpen.value = true
}
const handleCloseDialog = () => {
isDialogOpen.value = false
editingRule.value = null
formData.value = {
businessCode: '',
businessName: '',
estimatedReward: 0,
status: '生效中'
}
}
const handleSubmit = () => {
if (!formData.value.businessCode.trim() || !formData.value.businessName.trim() || formData.value.estimatedReward <= 0) {
ElMessage.error('请填写完整信息')
return
}
if (editingRule.value) {
emit('update', editingRule.value.id, {
businessCode: formData.value.businessCode,
businessName: formData.value.businessName,
estimatedReward: formData.value.estimatedReward,
status: formData.value.status
})
} else {
emit('add', {
businessCode: formData.value.businessCode,
businessName: formData.value.businessName,
estimatedReward: formData.value.estimatedReward,
status: formData.value.status
})
}
handleCloseDialog()
}
const handleToggleStatus = (id: string) => {
emit('toggleStatus', id)
}
const handleDeleteConfirm = async (id: string) => {
try {
await ElMessageBox.confirm(
'确定要删除这条业务规则吗?此操作无法撤销。',
'确认删除',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
)
emit('delete', id)
} catch {
// 用户取消删除
}
}
const handleDownloadTemplate = () => {
const template = '业务名称,业务代码,预计酬金\n示例业务,BEXAMPLE,100'
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = '业务规则批量上传模板.csv'
link.click()
ElMessage.success('模板下载成功')
}
const handleBatchUpload = () => {
fileInputRef.value?.click()
}
const handleFileChange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
// 重置文件输入框
target.value = ''
// 验证文件大小(2MB)
const maxSize = 2 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小超过2MB限制,请压缩后重新上传')
return
}
// 验证文件格式
const fileName = file.name.toLowerCase()
if (!fileName.endsWith('.csv')) {
ElMessage.error('目前仅支持 .csv 格式的文件')
return
}
try {
let data: any[] = []
if (fileName.endsWith('.csv')) {
// 解析 CSV 文件
const text = await file.text()
const lines = text.split('\n').filter(line => line.trim())
if (lines.length < 2) {
ElMessage.error('文件内容为空')
return
}
// 跳过表头,从第二行开始解析
for (let i = 1; i < lines.length; i++) {
const line = (lines[i] || '').trim()
if (!line) continue
const columns = line.split(',').map(col => col.trim())
if (columns.length >= 3) {
data.push({
businessName: columns[0],
businessCode: columns[1],
estimatedReward: columns[2]
})
}
}
} else {
// 解析 XLSX 文件 (暂时禁用)
ElMessage.error('XLSX功能暂未启用,请使用CSV格式')
return
// const arrayBuffer = await file.arrayBuffer()
// const workbook = XLSX.read(arrayBuffer, { type: 'array' })
// const sheetName = workbook.SheetNames[0]
// const worksheet = workbook.Sheets[sheetName]
// const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 })
// 跳过表头(XLSX 逻辑禁用中)
}
// 验证数据量
if (data.length === 0) {
ElMessage.error('文件中没有有效数据')
return
}
if (data.length > 1000) {
ElMessage.error('数据量超过1000条限制,请分批上传')
return
}
// 验证并导入数据
processUploadData(data)
} catch (error) {
console.error('文件解析失败:', error)
ElMessage.error('文件解析失败,请检查文件格式是否正确')
}
}
const processUploadData = (data: any[]) => {
const errors: string[] = []
let successCount = 0
let failedCount = 0
// 获取现有的业务代码集合
const existingCodes = new Set(props.rules.map(r => r.businessCode.toLowerCase()))
data.forEach((row, index) => {
const rowNum = index + 2 // Excel行号(从1开始,跳过表头)
// 验证必填字段
if (!row.businessName || !row.businessCode || !row.estimatedReward) {
errors.push(`第${rowNum}行:缺少必填字段`)
failedCount++
return
}
// 验证业务代码是否重复(与现有数据)
if (existingCodes.has(row.businessCode.toLowerCase())) {
errors.push(`第${rowNum}行:业务代码"${row.businessCode}"已存在,请修改后重新上传`)
failedCount++
return
}
// 验证酬金金额
const amount = parseFloat(row.estimatedReward)
if (isNaN(amount) || amount <= 0) {
errors.push(`第${rowNum}行:预计酬金格式不正确`)
failedCount++
return
}
// 验证通过,添加到系统
try {
emit('add', {
businessCode: row.businessCode,
businessName: row.businessName,
estimatedReward: amount,
status: '生效中'
})
// 将新添加的代码加入集合,避免批量数据内部重复
existingCodes.add(row.businessCode.toLowerCase())
successCount++
} catch (error) {
errors.push(`第${rowNum}行:添加失败`)
failedCount++
}
})
// 显示上传结果
uploadResult.value = {
success: successCount,
failed: failedCount,
errors: errors.slice(0, 20) // 最多显示20条错误
}
showUploadResult.value = true
if (successCount > 0 && failedCount === 0) {
ElMessage.success(`成功导入${successCount}条业务规则`)
} else if (successCount > 0 && failedCount > 0) {
ElMessage.warning(`成功导入${successCount}条,失败${failedCount}条`)
} else {
ElMessage.error(`导入失败,共${failedCount}条错误`)
}
}
</script>
<style scoped>
/* 搜索框样式 */
:deep(.search-input .el-input__wrapper) {
padding-left: 40px;
background-color: #f5f5f5;
border: none;
box-shadow: none;
}
:deep(.search-input .el-input__inner) {
background-color: transparent;
}
/* 状态选择器样式 */
:deep(.status-select .el-input__wrapper) {
background-color: #f5f5f5;
border: none;
box-shadow: none;
}
/* 表格样式优化:圆角由全局控制描边,这里仅保持圆角一致 */
:deep(.el-table) {
border-radius: 8px;
}
:deep(.el-table .el-table__header-wrapper) {
border-radius: 8px 8px 0 0;
}
:deep(.el-table .bg-blue-50\/50) {
background-color: rgba(59, 130, 246, 0.05) !important;
}
:deep(.el-table .hover\:bg-muted\/30:hover) {
background-color: rgba(0, 0, 0, 0.03) !important;
}
/* 分页样式 */
:deep(.el-pagination) {
justify-content: flex-end;
}
:deep(.el-pagination .el-pager li) {
min-width: 32px;
height: 32px;
line-height: 30px;
}
:deep(.el-pagination .btn-prev),
:deep(.el-pagination .btn-next) {
height: 32px;
line-height: 30px;
}
/* 强制修复dialog遮罩层定位问题 */
:deep(.el-overlay-dialog) {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 10001 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
padding: 0 !important;
}
:deep(.el-overlay-dialog .el-dialog) {
position: relative !important;
margin: 0 auto !important;
transform: none !important;
top: auto !important;
left: auto !important;
z-index: 10002 !important;
}
</style>
<template>
<div class="flex h-screen bg-neutral-100">
<!-- 侧边栏 -->
<div
:class="`flex flex-col transition-all duration-300 ease-in-out ${isSidebarCollapsed ? 'w-20' : 'w-[200px]'}`"
style="background-color: #001529"
>
<!-- Logo区域 -->
<div class="h-16 flex items-center justify-center px-6 border-b border-neutral-800">
<div v-if="isSidebarCollapsed" class="w-10 h-10 rounded-lg flex items-center justify-center">
<img :src="platformLogo" alt="平台Logo" class="h-9 object-contain" />
</div>
<h1 v-else class="text-white flex items-center gap-2 flex-nowrap whitespace-nowrap">
<img :src="platformLogo" alt="平台Logo" class="h-9 object-contain" />
<span class="text-[16px]">享零工云平台</span>
</h1>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 py-6 px-3 overflow-hidden">
<div class="space-y-6">
<!-- 业务管理菜单 -->
<div class="space-y-1">
<div v-if="!isSidebarCollapsed" class="px-4 mb-2">
<p class="text-xs text-neutral-500 uppercase tracking-wide">业务管理</p>
</div>
<template v-for="item in businessMenuItems" :key="item.id">
<el-tooltip
v-if="isSidebarCollapsed"
:content="item.label"
placement="right"
:delay="0"
>
<button
@click="handleMenuChange(item.id)"
:class="`
flex items-center transition-all relative
w-[45px] h-[45px] mx-auto justify-center p-0 rounded-[10px]
${activeMenu === item.id
? 'bg-brand-primary text-white'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100'
}
`"
style="display: flex; align-items: center; justify-content: center;"
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
</button>
</el-tooltip>
<button
v-else
@click="handleMenuChange(item.id)"
:class="`
flex items-center transition-all relative
w-full gap-3 px-4 py-3 rounded-lg
${activeMenu === item.id
? 'bg-brand-primary text-white'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100'
}
`"
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
<span class="text-[14px]">{{ item.label }}</span>
</button>
</template>
</div>
<!-- 系统管理菜单 -->
<div class="space-y-1">
<div v-if="!isSidebarCollapsed" class="px-4 mb-2">
<p class="text-xs text-neutral-500 uppercase tracking-wide">系统管理</p>
</div>
<template v-for="item in systemMenuItems" :key="item.id">
<el-tooltip
v-if="isSidebarCollapsed"
:content="item.label"
placement="right"
:delay="0"
>
<button
@click="handleMenuChange(item.id)"
:class="`
flex items-center transition-all relative
w-[45px] h-[45px] mx-auto justify-center p-0 rounded-[10px]
${activeMenu === item.id
? 'bg-brand-primary text-white'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100'
}
`"
style="display: flex; align-items: center; justify-content: center;"
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
</button>
</el-tooltip>
<button
v-else
@click="handleMenuChange(item.id)"
:class="`
flex items-center transition-all relative
w-full gap-3 px-4 py-3 rounded-lg
${activeMenu === item.id
? 'bg-brand-primary text-white'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100'
}
`"
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
<span class="text-[14px]">{{ item.label }}</span>
</button>
</template>
</div>
</div>
</nav>
<!-- 收缩按钮 -->
<div class="p-4 border-t border-neutral-800">
<button
@click="isSidebarCollapsed = !isSidebarCollapsed"
:class="`w-full flex items-center py-2 rounded transition-colors ${isSidebarCollapsed ? 'justify-center' : 'justify-start pl-4 hover:bg-white/10 group'}`"
>
<div :class="`h-6 w-6 transition-transform duration-300 text-neutral-400 flex items-center justify-center ${isSidebarCollapsed ? '' : 'group-hover:text-white'} ${isSidebarCollapsed ? 'rotate-180' : ''}`">
<!-- 展开按钮图标 -->
<svg class="block size-full" fill="none" preserveAspectRatio="none" viewBox="0 0 16 16">
<g>
<rect fill="currentColor" height="1" rx="0.5" width="10" x="3" y="3" />
<rect fill="currentColor" height="1" rx="0.5" width="6" x="3" y="6" />
<path d="M11.5 9.5L13 8L11.5 6.5V7.5H10V8.5H11.5V9.5Z" fill="currentColor" />
<rect fill="currentColor" height="1" rx="0.5" width="10" x="3" y="12" />
<rect fill="currentColor" height="1" rx="0.5" width="6" x="3" y="9" />
</g>
</svg>
</div>
</button>
</div>
</div>
<!-- 右侧主内容区 -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- 顶部Header -->
<header class="h-16 bg-white border-b border-neutral-200 flex items-center justify-between px-6">
<!-- 面包屑导航 -->
<div class="flex gap-1 items-center">
<!-- 一级导航 -->
<button
@click="handleBreadcrumbClick('level1')"
class="flex flex-col font-['PingFang_SC:Regular',sans-serif] justify-center leading-[0] not-italic text-[#909399] text-[14px] text-nowrap tracking-[-0.01px] hover:text-brand-primary cursor-pointer transition-colors"
>
<p class="leading-[22px] whitespace-pre">
{{ activeView === 'orders' || activeView === 'order-detail' ? '业务管理' : '权限管理' }}
</p>
</button>
<!-- 分隔符 -->
<div class="relative shrink-0 size-[12px]">
<div class="absolute inset-[13.77%_30.91%]">
<div class="absolute inset-0">
<svg class="block size-full" fill="none" preserveAspectRatio="none" viewBox="0 0 5 9">
<path d="M0 0L5 4.5L0 9" fill="#909399" />
</svg>
</div>
</div>
</div>
<!-- 二级导航 -->
<button
@click="handleBreadcrumbClick('level2')"
:class="`flex flex-col font-['PingFang_SC:Regular',sans-serif] justify-center leading-[0] not-italic text-[14px] text-nowrap tracking-[-0.01px] transition-colors ${
activeView === 'order-detail' ? 'text-[#909399] hover:text-brand-primary cursor-pointer' : 'text-[#303133]'
}`"
>
<p class="leading-[22px] whitespace-pre">
{{ currentMenuTitle }}
</p>
</button>
<!-- 三级导航(仅订单详情时显示) -->
<template v-if="activeView === 'order-detail'">
<div class="relative shrink-0 size-[12px]">
<div class="absolute inset-[13.77%_30.91%]">
<div class="absolute inset-0">
<svg class="block size-full" fill="none" preserveAspectRatio="none" viewBox="0 0 5 9">
<path d="M0 0L5 4.5L0 9" fill="#909399" />
</svg>
</div>
</div>
</div>
<div class="flex flex-col font-['PingFang_SC:Regular',sans-serif] justify-center leading-[0] not-italic text-[#303133] text-[14px] text-nowrap tracking-[-0.01px]">
<p class="leading-[22px] whitespace-pre">订单详情</p>
</div>
</template>
</div>
<!-- 用户下拉菜单 -->
<el-dropdown trigger="click" @command="handleCommand">
<el-button
text
class="flex items-center gap-3 rounded-lg px-3 py-2"
>
<div class="text-left">
<p class="text-sm text-neutral-900 text-[14px] font-bold">{{ currentUser?.username || '管理员' }}</p>
<p class="text-xs text-neutral-500">
{{ currentUser?.role === 'admin' ? '系统管理员' : '普通用户' }}
</p>
</div>
<ChevronDown :size="16" class="text-neutral-400" />
</el-button>
<template #dropdown>
<el-dropdown-menu class="w-[140px] bg-neutral-800 border-neutral-700 p-1">
<el-dropdown-item
command="logout"
class="text-error hover:text-error focus:text-error hover:bg-neutral-700 focus:bg-neutral-700 cursor-pointer rounded-md px-3 py-2"
>
<LogOut :size="16" class="mr-2" />
<span class="text-[14px] text-[#181818] hover:text-[#3b82f6] transition-colors">退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</header>
<!-- 页面内容区 -->
<main class="flex-1 overflow-auto p-6 bg-[#F0F2F5]">
<!-- 订单监控页面 -->
<OrderMonitoring
v-if="activeView === 'orders'"
:orders="orders"
:current-user="currentUser"
@view-order-detail="handleViewOrderDetail"
@orders-update="handleOrdersUpdate"
/>
<!-- 订单详情页面 -->
<OrderDetail
v-else-if="activeView === 'order-detail' && selectedOrderId && getOrderById(selectedOrderId)"
:order="getOrderById(selectedOrderId)!"
:business-rules="businessRules"
:current-user="currentUser"
@update="handleUpdateOrder"
@back="handleBackToOrders"
/>
<!-- 业务酬金管理页面 -->
<BusinessRulesManagement
v-else-if="activeView === 'business'"
:rules="businessRules"
@add="handleAddBusinessRule"
@update="handleUpdateBusinessRule"
@toggle-status="handleToggleBusinessRuleStatus"
@delete="handleDeleteBusinessRule"
/>
<!-- 角色管理页面 -->
<RoleManagement
v-else-if="activeView === 'roles'"
:roles="roles"
:permissions="permissions"
@add-role="handleAddRole"
@update-role="handleUpdateRole"
/>
<UserManagement
v-else-if="activeView === 'users'"
:users="users"
:roles="roles"
:organizations="organizations"
:current-user-id="currentUserId"
@add-user="handleAddUser"
@update-user="handleUpdateUser"
@delete-user="handleDeleteUser"
/>
<div v-else class="p-8 bg-white rounded-lg">
<h2 class="text-xl font-semibold mb-4">欢迎</h2>
<p class="text-neutral-600">请从左侧菜单选择一个功能。</p>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
LayoutDashboard,
DollarSign,
LogOut,
Settings,
Users,
User as UserIcon,
ChevronRight,
ChevronDown
} from 'lucide-vue-next'
import { ElMessage, ElMessageBox } from 'element-plus'
// 导入图片资源
import platformLogoImg from '@/assets/8a3322d5ba8c2ae3592af24d73566a63828a3a27.png'
// 导入子组件
import OrderMonitoring from './OrderMonitoring.vue'
import OrderDetail from './OrderDetail.vue'
import BusinessRulesManagement from './BusinessRulesManagement.vue'
import RoleManagement from './RoleManagement.vue'
import UserManagement from './UserManagement.vue'
import RoleIcon from './icons/RoleIcon.vue'
const platformLogo = ref(platformLogoImg)
interface DesktopMainProps {
currentUser: { username: string; role: 'admin' | 'viewer' } | null;
}
const props = defineProps<DesktopMainProps>()
const emit = defineEmits(['logout'])
// 响应式数据
const isSidebarCollapsed = ref(false)
const activeMenu = ref<string>('orders') // 当前激活的菜单
const activeView = ref<string>('orders') // 当前显示的视图
const selectedOrderId = ref<string | null>(null) // 当前查看的订单ID
// 菜单配置
const businessMenuItems = [
{ id: 'orders', label: '登记订单管理', icon: LayoutDashboard },
{ id: 'business', label: '业务酬金管理', icon: DollarSign }
]
const systemMenuItems = [
{ id: 'roles', label: '角色管理', icon: RoleIcon },
{ id: 'users', label: '账号管理', icon: Users }
]
const menuMap: Record<string, string> = {
orders: '登记订单管理',
business: '业务酬金管理',
roles: '角色管理',
users: '账号管理',
}
const currentMenuTitle = computed(() => {
if (activeView.value === 'order-detail') {
return '订单详情'
}
return menuMap[activeView.value] || '首页'
})
// 业务规则数据
const businessRules = ref([
{
id: 'BIZ001',
businessCode: 'B5G001',
businessName: '5G套餐办理',
estimatedReward: 50,
status: '生效中',
createTime: '2025-10-20 10:00'
},
{
id: 'BIZ002',
businessCode: 'BKDXZ01',
businessName: '宽带新装',
estimatedReward: 80,
status: '生效中',
createTime: '2025-10-20 10:05'
},
{
id: 'BIZ003',
businessCode: 'BHFCZ01',
businessName: '话费充值',
estimatedReward: 30,
status: '生效中',
createTime: '2025-10-20 10:10'
},
{
id: 'BIZ004',
businessCode: 'BLLB001',
businessName: '流量包办理',
estimatedReward: 40,
status: '已停用',
createTime: '2025-10-20 10:15'
}
])
// 模拟订单数据
// 使用空数组,让 OrderMonitoring 组件自动生成模拟数据
const orders = ref([])
// 存储从 OrderMonitoring 组件获取的订单数据
const allOrders = ref([])
// 类型定义
interface Role {
id: string
name: string
description?: string
level: '地市级' | '区县级' | '一线人员'
permissionIds: string[]
status: '启用' | '禁用'
createTime?: string
}
interface Permission {
id: string
name: string
code: string
description?: string
parentId?: string
children?: Permission[]
}
// 角色管理数据
const roles = ref<Role[]>([
{
id: 'role-001',
name: '区县全权管理员',
description: '拥有区县级别的所有管理权限,可以管理订单、用户和业务规则',
level: '区县级',
permissionIds: [
'order',
'order:view',
'order:complete',
'order:reward',
'order:approve',
'business',
'business:view',
'business:create',
'business:edit',
'system',
'system:role',
'system:role:view',
'system:role:create',
'system:role:edit',
'system:account',
'system:account:view',
'system:account:create',
'system:account:edit'
],
status: '启用',
createTime: '2025-10-20 09:00:00'
},
{
id: 'role-002',
name: '订单管理员',
description: '负责订单的日常管理和处理',
level: '一线人员',
permissionIds: ['order', 'order:view', 'order:complete', 'order:reward', 'order:approve'],
status: '启用',
createTime: '2025-10-20 09:30:00'
},
{
id: 'role-003',
name: '业务酬金管理员',
description: '负责业务酬金的配置和维护',
level: '区县级',
permissionIds: ['business', 'business:view', 'business:create', 'business:edit'],
status: '启用',
createTime: '2025-10-20 10:00:00'
}
])
// 权限数据
const permissions = ref<Permission[]>([
{
id: 'order',
name: '订单管理',
code: 'order',
description: '订单相关的所有权限',
children: [
{
id: 'order:view',
name: '查看订单',
code: 'order:view',
description: '查看订单列表和详情',
parentId: 'order'
},
{
id: 'order:complete',
name: '填写办结信息',
code: 'order:complete',
description: '填写CRM订单编号和办理备注',
parentId: 'order'
},
{
id: 'order:reward',
name: '填写酬金金额',
code: 'order:reward',
description: '填写和修改实际发放酬金',
parentId: 'order'
},
{
id: 'order:approve',
name: '审核',
code: 'order:approve',
description: '审核通过或驳回订单',
parentId: 'order'
}
]
},
{
id: 'business',
name: '业务酬金管理',
code: 'business',
description: '业务酬金相关权限',
children: [
{
id: 'business:view',
name: '查看业务酬金',
code: 'business:view',
description: '查看业务酬金列表',
parentId: 'business'
},
{
id: 'business:create',
name: '创建业务酬金',
code: 'business:create',
description: '创建新的业务酬金',
parentId: 'business'
},
{
id: 'business:edit',
name: '编辑业务酬金',
code: 'business:edit',
description: '修改和停用业务酬金',
parentId: 'business'
}
]
},
{
id: 'system',
name: '系统管理',
code: 'system',
description: '系统管理相关权限',
children: [
{
id: 'system:role',
name: '角色管理',
code: 'system:role',
description: '角色管理相关权限',
parentId: 'system',
children: [
{
id: 'system:role:view',
name: '查看角色',
code: 'system:role:view',
description: '查看角色列表',
parentId: 'system:role'
},
{
id: 'system:role:create',
name: '创建角色',
code: 'system:role:create',
description: '创建新角色',
parentId: 'system:role'
},
{
id: 'system:role:edit',
name: '编辑角色',
code: 'system:role:edit',
description: '编辑角色信息和权限',
parentId: 'system:role'
}
]
},
{
id: 'system:account',
name: '账号管理',
code: 'system:account',
description: '账号管理相关权限',
parentId: 'system',
children: [
{
id: 'system:account:view',
name: '查看账号',
code: 'system:account:view',
description: '查看账号列表',
parentId: 'system:account'
},
{
id: 'system:account:create',
name: '创建账号',
code: 'system:account:create',
description: '创建新账号',
parentId: 'system:account'
},
{
id: 'system:account:edit',
name: '编辑账号',
code: 'system:account:edit',
description: '编辑账号信息',
parentId: 'system:account'
}
]
}
]
}
])
// 根据订单ID获取订单详情
const getOrderById = (orderId: string) => {
return allOrders.value.find(order => order.id === orderId)
}
// 方法
const handleMenuChange = (menuId: string) => {
activeMenu.value = menuId
activeView.value = menuId
}
const handleCommand = (command: string) => {
if (command === 'logout') {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
emit('logout')
ElMessage.success('已退出登录')
}).catch(() => {
// 用户取消操作
})
} else {
ElMessage.info(`点击了 ${command}`)
}
}
const handleViewOrderDetail = (order: any) => {
selectedOrderId.value = order.id
activeView.value = 'order-detail'
}
const handleBackToOrders = () => {
selectedOrderId.value = null
activeView.value = 'orders'
}
const handleUpdateOrder = (updates: any) => {
// 更新 allOrders 中的订单数据
const orderIndex = allOrders.value.findIndex(order => order.id === selectedOrderId.value)
if (orderIndex !== -1) {
allOrders.value[orderIndex] = { ...allOrders.value[orderIndex], ...updates }
}
console.log('更新订单:', updates)
ElMessage.success('订单更新成功')
}
// 接收来自 OrderMonitoring 组件的订单数据
const handleOrdersUpdate = (ordersList: any[]) => {
allOrders.value = ordersList
}
// 面包屑导航点击处理
const handleBreadcrumbClick = (level: string) => {
if (level === 'level1') {
if (activeView.value === 'orders' || activeView.value === 'order-detail') {
activeView.value = 'orders'
activeMenu.value = 'orders'
}
} else if (level === 'level2') {
if (activeView.value === 'order-detail') {
activeView.value = 'orders'
}
}
}
// 业务规则管理方法
const handleAddBusinessRule = (ruleData: Omit<any, 'id' | 'createTime'>) => {
const newRule = {
id: `BIZ${String(businessRules.value.length + 1).padStart(3, '0')}`,
...ruleData,
createTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
businessRules.value.push(newRule)
ElMessage.success('业务规则创建成功')
console.log('新增业务规则:', newRule)
}
const handleUpdateBusinessRule = (ruleId: string, updates: any) => {
const ruleIndex = businessRules.value.findIndex(rule => rule.id === ruleId)
if (ruleIndex !== -1) {
businessRules.value[ruleIndex] = {
...businessRules.value[ruleIndex],
...updates,
updateTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
ElMessage.success('业务规则更新成功')
console.log('更新业务规则:', ruleId, updates)
}
}
const handleToggleBusinessRuleStatus = (ruleId: string) => {
const ruleIndex = businessRules.value.findIndex(rule => rule.id === ruleId)
if (ruleIndex !== -1) {
const currentStatus = businessRules.value[ruleIndex].status
const newStatus = currentStatus === '生效中' ? '已停用' : '生效中'
businessRules.value[ruleIndex].status = newStatus
businessRules.value[ruleIndex].updateTime = new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
ElMessage.success(`业务规则已${newStatus === '生效中' ? '启用' : '停用'}`)
console.log('切换业务规则状态:', ruleId, newStatus)
}
}
const handleDeleteBusinessRule = (ruleId: string) => {
const ruleIndex = businessRules.value.findIndex(rule => rule.id === ruleId)
if (ruleIndex !== -1) {
businessRules.value.splice(ruleIndex, 1)
ElMessage.success('业务规则删除成功')
console.log('删除业务规则:', ruleId)
}
}
// 角色管理方法
const handleAddRole = (roleData: Omit<Role, 'id' | 'createTime'>) => {
const newRole = {
id: `role-${Date.now()}`,
...roleData,
createTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
roles.value.push(newRole)
console.log('新增角色:', newRole)
}
const handleUpdateRole = (roleId: string, updates: Partial<Role>) => {
const roleIndex = roles.value.findIndex(role => role.id === roleId)
if (roleIndex !== -1) {
roles.value[roleIndex] = { ...roles.value[roleIndex], ...updates }
console.log('更新角色:', roleId, updates)
}
}
// 组织架构数据
const organizations = ref([
{
id: 'org-001',
name: '杭州市移动',
type: '地市',
children: [
{
id: 'org-002',
name: '西湖区',
type: '区县',
parentId: 'org-001',
children: [
{
id: 'org-005',
name: '客户经理团队',
type: '客户经理团队',
parentId: 'org-002'
}
]
},
{
id: 'org-003',
name: '滨江区',
type: '区县',
parentId: 'org-001',
children: [
{
id: 'org-006',
name: '客户经理团队',
type: '客户经理团队',
parentId: 'org-003'
}
]
},
{
id: 'org-004',
name: '余杭区',
type: '区县',
parentId: 'org-001',
children: [
{
id: 'org-007',
name: '客户经理团队',
type: '客户经理团队',
parentId: 'org-004'
}
]
}
]
}
])
// 当前登录用户ID
const currentUserId = ref('user-001')
// 用户数据
const users = ref([
{
id: 'user-001',
username: 'admin',
realName: '系统管理员',
organizationId: 'org-001',
roleId: 'role-001',
phone: '13800138000',
email: 'admin@cmcc.com',
status: '正常',
createTime: '2025-10-20 08:00:00',
creatorId: 'system'
},
{
id: 'user-002',
username: 'zhangsan',
realName: '张三',
organizationId: 'org-002',
roleId: 'role-002',
phone: '13800138001',
email: 'zhangsan@cmcc.com',
status: '正常',
createTime: '2025-10-20 09:00:00',
creatorId: 'user-001'
},
{
id: 'user-003',
username: 'lisi',
realName: '李四',
organizationId: 'org-003',
roleId: 'role-002',
phone: '13800138002',
email: 'lisi@cmcc.com',
status: '禁用',
createTime: '2025-10-20 10:00:00',
creatorId: 'user-001'
},
{
id: 'user-004',
username: 'wangwu',
realName: '王五',
organizationId: 'org-005',
roleId: 'role-003',
phone: '13800138003',
email: 'wangwu@cmcc.com',
status: '正常',
createTime: '2025-10-20 11:00:00',
creatorId: 'user-001'
}
])
// 用户管理方法
const handleAddUser = (userData: any) => {
const newUser = {
id: `user-${Date.now()}`,
...userData,
createTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
users.value.push(newUser)
console.log('新增用户:', newUser)
}
const handleUpdateUser = (userId: string, updates: any) => {
const userIndex = users.value.findIndex(user => user.id === userId)
if (userIndex !== -1) {
users.value[userIndex] = { ...users.value[userIndex], ...updates }
console.log('更新用户:', userId, updates)
}
}
const handleDeleteUser = (userId: string) => {
const userIndex = users.value.findIndex(user => user.id === userId)
if (userIndex !== -1) {
users.value.splice(userIndex, 1)
console.log('删除用户:', userId)
}
}
</script>
<style scoped>
/* 隐藏滚动条但保持滚动功能 */
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, and Opera */
}
/* 移除右上角用户信息按钮的悬浮背景效果 */
:deep(header .el-button.is-text:hover),
:deep(header .el-button.is-text:focus),
:deep(header .el-button.is-text:active) {
background-color: transparent !important;
}
</style>
\ No newline at end of file \ No newline at end of file
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
You’ve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
<template>
<div class="min-h-screen flex items-center justify-center relative">
<!-- 背景层 -->
<div class="absolute inset-0">
<div class="absolute inset-0 bg-[#001529]" />
<div class="absolute inset-0 opacity-50 overflow-hidden">
<img
alt=""
class="absolute h-full left-0 max-w-none top-0 w-auto min-w-full object-cover"
:src="loginBackground"
/>
</div>
</div>
<!-- 内容区域 -->
<div class="relative z-10 w-full flex flex-col items-center justify-center gap-8 px-6">
<!-- Logo和标题区 -->
<div class="flex flex-col items-center gap-2 w-full max-w-[448px]">
<div class="relative w-[88px] h-[88px]">
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<img
alt="享零工云平台"
class="absolute h-[85.72%] left-[-8.06%] max-w-none top-[7.7%] w-[329.57%]"
:src="logoIcon"
/>
</div>
</div>
<h1 class="text-white text-center text-[24px] font-bold">享零工云平台</h1>
<p class="text-white/90 text-center">中国移动地市管理系统</p>
</div>
<!-- 登录卡片 -->
<div
class="relative w-full max-w-[448px] backdrop-blur-[10px] backdrop-filter rounded-[10px] pt-10 pb-10 px-8 border border-[rgba(255,255,255,0.6)] shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]"
:style="{
background: 'linear-gradient(142deg, rgba(255, 255, 255, 0.32) 6%, rgba(255, 255, 255, 0.20) 90.9%)'
}"
>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@submit.prevent="handleSubmit"
class="relative flex flex-col gap-7"
>
<!-- 手机号 -->
<div class="flex flex-col gap-2.5">
<label for="phone" class="text-white">
手机号
</label>
<el-input
id="phone"
v-model="loginForm.phone"
type="tel"
placeholder="请输入手机号"
maxlength="11"
class="login-input"
:disabled="isLoading"
size="large"
/>
</div>
<!-- 图形验证码 -->
<div class="flex flex-col gap-2.5">
<label for="captcha" class="text-white">
图形验证码
</label>
<div class="flex gap-2">
<el-input
id="captcha"
v-model="loginForm.captchaInput"
type="text"
placeholder="请输入图形验证码"
maxlength="4"
class="login-input flex-1"
:disabled="isLoading"
size="large"
/>
<div
class="relative h-12 w-[120px] bg-white rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
@click="generateCaptcha"
title="点击刷新验证码"
>
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
class="w-full h-full object-cover"
/>
<RefreshCw class="absolute top-1 right-1 w-3 h-3 text-gray-400" />
</div>
</div>
</div>
<!-- 短信验证码 -->
<div class="flex flex-col gap-2.5">
<label for="smsCode" class="text-white">
短信验证码
</label>
<div class="flex gap-2">
<el-input
id="smsCode"
v-model="loginForm.smsCode"
type="text"
placeholder="请输入短信验证码"
maxlength="6"
class="login-input flex-1"
:disabled="isLoading"
size="large"
/>
<el-button
@click="handleSendSms"
:disabled="!canSendSms || isLoading"
class="sms-button-white"
size="large"
>
{{ countdown > 0 ? `${countdown}秒后重试` : '获取验证码' }}
</el-button>
</div>
</div>
<el-button
type="primary"
@click="handleSubmit"
class="login-submit-button"
:loading="isLoading"
size="large"
>
{{ isLoading ? '登录中...' : '登录' }}
</el-button>
</el-form>
</div>
</div>
<!-- 品牌背书 -->
<div class="absolute bottom-8 left-0 right-0 z-10 text-center">
<p class="text-white/60 text-xs">
Copyright 2016-2025. ICP16058130-2 . All Rights Reserved.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { RefreshCw } from 'lucide-vue-next'
// 导入图片资源
import loginBackgroundImg from '@/assets/7f0599d246217c734650d105801453a4919de13c.png'
import logoIconImg from '@/assets/83c9823faecc66b9d1978deb8ed7085f80e226b3.png'
const loginBackground = ref(loginBackgroundImg)
const logoIcon = ref(logoIconImg)
// 接口定义
interface LoginForm {
phone: string
captchaInput: string
smsCode: string
}
interface LoginProps {
onLogin: (username: string, role: 'admin' | 'viewer') => void
}
// Props
const props = defineProps<LoginProps>()
// 响应式数据
const loginFormRef = ref<FormInstance>()
const isLoading = ref(false)
const captchaText = ref('')
const captchaImage = ref('')
const countdown = ref(0)
const canSendSms = ref(true)
// 表单数据
const loginForm = reactive<LoginForm>({
phone: '13800000001',
captchaInput: '',
smsCode: ''
})
// 表单验证规则
const loginRules: FormRules<LoginForm> = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
captchaInput: [
{ required: true, message: '请输入图形验证码', trigger: 'blur' },
{ len: 4, message: '验证码长度为4位', trigger: 'blur' }
],
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ len: 6, message: '验证码长度为6位', trigger: 'blur' }
]
}
// 生成图形验证码 - 完全复制React版本的逻辑
const generateCaptcha = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let text = ''
for (let i = 0; i < 4; i++) {
text += chars.charAt(Math.floor(Math.random() * chars.length))
}
captchaText.value = text
// 生成验证码图片 (使用 canvas)
const canvas = document.createElement('canvas')
canvas.width = 120
canvas.height = 40
const ctx = canvas.getContext('2d')
if (ctx) {
// 背景
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 干扰线
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = `rgba(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255},0.3)`
ctx.beginPath()
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height)
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height)
ctx.stroke()
}
// 验证码文字
ctx.font = 'bold 24px Arial'
ctx.textBaseline = 'middle'
for (let i = 0; i < text.length; i++) {
ctx.fillStyle = `rgb(${Math.random() * 100},${Math.random() * 100},${Math.random() * 100})`
const x = 20 + i * 25
const y = 20 + (Math.random() - 0.5) * 8
const angle = (Math.random() - 0.5) * 0.4
ctx.save()
ctx.translate(x, y)
ctx.rotate(angle)
ctx.fillText(text[i], 0, 0)
ctx.restore()
}
captchaImage.value = canvas.toDataURL()
}
}
// 倒计时逻辑
let countdownTimer: NodeJS.Timeout | null = null
const startCountdown = () => {
countdown.value = 60
canSendSms.value = false
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
canSendSms.value = true
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
}, 1000)
}
// 发送短信验证码
const handleSendSms = () => {
// 验证手机号
if (!loginForm.phone.trim()) {
ElMessage.error('请输入手机号')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(loginForm.phone)) {
ElMessage.error('请输入正确的手机号')
return
}
// 验证图形验证码
if (!loginForm.captchaInput.trim()) {
ElMessage.error('请输入图形验证码')
return
}
if (loginForm.captchaInput.toUpperCase() !== captchaText.value) {
ElMessage.error('图形验证码错误')
generateCaptcha()
loginForm.captchaInput = ''
return
}
// 模拟发送短信
ElMessage.success('验证码已发送至您的手机,请注意查收')
startCountdown()
// 演示用:实际验证码为 123456
console.log('演示验证码:123456')
}
// 登录提交
const handleSubmit = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
} catch {
return
}
// 验证图形验证码
if (loginForm.captchaInput.toUpperCase() !== captchaText.value) {
ElMessage.error('图形验证码错误')
generateCaptcha()
loginForm.captchaInput = ''
return
}
isLoading.value = true
// 模拟登录验证 - 完全复制React版本的逻辑
setTimeout(() => {
// 演示账号:
// 13800000001 验证码123456 - 管理员
// 13800000002 验证码123456 - 普通用户
if (loginForm.phone === '13800000001' && loginForm.smsCode === '123456') {
ElMessage.success('登录成功')
props.onLogin(loginForm.phone, 'admin')
} else if (loginForm.phone === '13800000002' && loginForm.smsCode === '123456') {
ElMessage.success('登录成功')
props.onLogin(loginForm.phone, 'viewer')
} else {
ElMessage.error('手机号或验证码错误')
isLoading.value = false
generateCaptcha()
loginForm.captchaInput = ''
}
}, 800)
}
// 生命周期
onMounted(() => {
generateCaptcha()
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
}
})
</script>
<style scoped>
/* 自定义Element Plus样式以匹配React版本 */
:deep(.login-input .el-input__wrapper) {
height: 48px;
background-color: #f3f3f5;
border: none;
border-radius: 8px;
box-shadow: none;
}
:deep(.login-input .el-input__wrapper:hover) {
border: none;
box-shadow: none;
}
:deep(.login-input .el-input__wrapper.is-focus) {
background-color: #f3f3f5;
border: none;
box-shadow: none;
}
:deep(.sms-button) {
height: 48px;
padding: 0 16px;
background-color: white;
color: #3b82f6;
border: 1px solid transparent;
border-radius: 8px;
white-space: nowrap;
}
:deep(.sms-button:hover) {
background-color: #eff6ff;
color: #3b82f6;
border-color: transparent;
}
:deep(.sms-button.is-disabled) {
background-color: #f3f4f6;
color: #9ca3af;
border-color: transparent;
}
/* 白色边框按钮样式 */
:deep(.sms-button-white) {
height: 48px;
padding: 0 16px;
background-color: white;
color: #3b82f6;
border: 1px solid #d1d5db;
border-radius: 8px;
white-space: nowrap;
}
:deep(.sms-button-white:hover) {
background-color: #eff6ff;
color: #2563eb;
border-color: #d1d5db;
}
:deep(.sms-button-white.is-disabled) {
background-color: #f3f4f6;
color: #9ca3af;
border-color: #e5e7eb;
}
:deep(.login-submit-button) {
width: 100%;
height: 48px;
background-color: #3b82f6;
border-color: #3b82f6;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin-top: 8px;
}
:deep(.login-submit-button:hover) {
background-color: #2563eb;
border-color: #2563eb;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
/* 表单标签样式 */
label {
font-size: 16px;
font-weight: 500;
line-height: 1.5;
}
</style>
<template>
<div class="space-y-6">
<!-- 操作按钮区 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<el-button
@click="$emit('back')"
class="border-neutral-300 bg-white"
>
<ArrowLeft class="w-4 h-4 mr-2" />
返回
</el-button>
<h1 class="text-2xl font-bold text-neutral-900 m-0" style="margin-left: 8px;">订单详情</h1>
</div>
<div class="flex items-center gap-2">
<!-- 待办理:关闭订单 + 提交审核 -->
<template v-if="order.businessStatus === '待办理'">
<el-button
@click="isCloseDialogOpen = true"
class="border-neutral-500 text-neutral-700"
>
关闭订单
</el-button>
<el-button
type="primary"
@click="handleSaveCompleteInfo"
class="bg-brand-primary"
>
提交审核
</el-button>
</template>
<!-- 办理成功:关闭订单 + 提交审核 -->
<template v-else-if="order.businessStatus === '办理成功' && !order.reviewStatus">
<el-button
@click="isCloseDialogOpen = true"
class="border-neutral-500 text-neutral-700"
>
关闭订单
</el-button>
<el-button
type="primary"
@click="handleSubmitReview"
class="bg-brand-primary"
>
提交审核
</el-button>
</template>
<!-- 办理成功+审核驳回:关闭订单 + 重新提交审核 -->
<template v-else-if="order.businessStatus === '办理成功' && order.reviewStatus === '审核驳回'">
<el-button
@click="isCloseDialogOpen = true"
class="border-neutral-500 text-neutral-700"
>
关闭订单
</el-button>
<el-button
type="primary"
@click="handleSubmitReview"
class="bg-brand-primary"
>
重新提交审核
</el-button>
</template>
<!-- 待审核:关闭订单 + 驳回 + 通过 -->
<template v-else-if="order.businessStatus === '办理成功' && order.reviewStatus === '待审核' && currentUser?.role === 'admin'">
<el-button
@click="isCloseDialogOpen = true"
class="border-neutral-500 text-neutral-700"
>
关闭订单
</el-button>
<el-dropdown @command="handleAuditCommand">
<el-button type="primary" class="bg-brand-primary">
审核订单
<el-icon class="ml-1">
<ArrowDown />
</el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="approve" class="flex items-center gap-2" style="color: #3b82f6;">
<Check class="w-4 h-4" /> 审核通过
</el-dropdown-item>
<el-dropdown-item command="reject" class="flex items-center gap-2" style="color: #d4183d;">
<X class="w-4 h-4" /> 审核驳回
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<!-- 审核通过:仅显示关闭订单;业务状态为已关闭时不显示任何操作按钮 -->
<template v-else-if="order.businessStatus !== '已关闭'">
<el-button
@click="isCloseDialogOpen = true"
class="border-neutral-500 text-neutral-700"
>
关闭订单
</el-button>
</template>
</div>
</div>
<!-- 主内容区 - 左右两列布局 -->
<div class="grid grid-cols-3 gap-6">
<!-- 左侧列 - 占2/3 -->
<div class="col-span-2 space-y-6">
<!-- 订单基础信息和状态信息 -->
<el-card class="p-6">
<h3 class="text-neutral-900 font-bold mb-4">订单基础信息</h3>
<div class="grid grid-cols-2 gap-x-6 gap-y-5">
<div>
<label class="text-neutral-500 text-sm">订单ID</label>
<p class="mt-2 font-mono text-neutral-900">{{ order.id }}</p>
</div>
<div>
<label class="text-neutral-500 text-sm">能人</label>
<p class="mt-2 text-neutral-900">{{ order.registerPersonName }}</p>
</div>
<div>
<label class="text-neutral-500 text-sm">能人手机号</label>
<p class="mt-2 text-neutral-900">{{ order.registerPersonPhone }}</p>
</div>
<div>
<label class="text-neutral-500 text-sm">登记时间</label>
<p class="mt-2 text-neutral-700">{{ order.registerTime }}</p>
</div>
<div>
<label class="text-neutral-500 text-sm">客户号码</label>
<p class="mt-2 text-neutral-900">{{ order.customerPhone }}</p>
</div>
<div>
<label class="text-neutral-500 text-sm">业务名称</label>
<p class="mt-2 text-neutral-900">{{ order.businessName }}</p>
</div>
<div>
<label class="text-neutral-500 text-sm">业务状态</label>
<div class="mt-2">
<span
:class="getBusinessStatusClass(order.businessStatus)"
class="px-2 py-1 rounded text-xs font-medium"
>
{{ order.businessStatus }}
</span>
</div>
</div>
<div v-if="order.reviewStatus">
<label class="text-neutral-500 text-sm">审核状态</label>
<div class="mt-2">
<span
:class="getReviewStatusClass(order.reviewStatus)"
class="px-2 py-1 rounded text-xs font-medium"
>
{{ order.reviewStatus }}
</span>
</div>
</div>
<div v-if="order.remarks" class="col-span-2">
<label class="text-neutral-500 text-sm">备注信息</label>
<p class="mt-2 text-neutral-700">{{ order.remarks }}</p>
</div>
</div>
</el-card>
<!-- 操作日志 -->
<el-card v-if="order.operationLogs && order.operationLogs.length > 0" class="p-6">
<h3 class="text-neutral-900 font-bold mb-4">操作日志</h3>
<div class="space-y-5">
<div
v-for="(log, index) in order.operationLogs"
:key="log.id"
class="flex gap-4"
>
<div class="flex flex-col items-center">
<div class="w-3 h-3 rounded-full bg-brand-primary"></div>
<div
v-if="index < order.operationLogs!.length - 1"
class="w-0.5 flex-1 bg-neutral-300 my-1"
></div>
</div>
<div class="flex-1 pb-2">
<div class="flex items-center gap-2 mb-1.5">
<span class="text-neutral-900">{{ log.action }}</span>
<el-tag size="small" type="info">{{ log.operator }}</el-tag>
</div>
<p class="text-sm text-neutral-600 mb-1">{{ log.details }}</p>
<p class="text-xs text-neutral-500">{{ log.time }}</p>
</div>
</div>
</div>
</el-card>
<!-- 订单关闭信息 -->
<el-card v-if="order.businessStatus === '已关闭' && order.closeReason" class="p-6 bg-neutral-50">
<h3 class="text-neutral-900 font-bold mb-4">订单关闭信息</h3>
<div>
<label class="text-neutral-700 text-sm">订单关闭原因</label>
<div class="mt-3 p-3 bg-neutral-100 border border-neutral-300 rounded">
<p class="text-sm text-neutral-700">{{ order.closeReason }}</p>
</div>
</div>
</el-card>
</div>
<!-- 右侧列 - 占1/3 -->
<div class="col-span-1 space-y-6">
<!-- 办结信息录入区 -->
<el-card class="p-6">
<h3 class="text-neutral-900 font-bold mb-4">办结信息录入区</h3>
<div class="space-y-5">
<div>
<label class="text-neutral-900 text-sm">
CRM订单编号 <span class="text-error">*</span>
</label>
<el-input
v-if="canEditCompleteInfo"
v-model="crmOrderNumber"
placeholder="请输入CRM订单编号"
class="mt-2"
/>
<p v-else class="mt-2 text-neutral-700">{{ order.crmOrderNumber || '-' }}</p>
</div>
<div>
<label class="text-neutral-900 text-sm">办理订单备注(选填)</label>
<el-input
v-if="canEditCompleteInfo"
v-model="processRemark"
type="textarea"
:rows="3"
placeholder="请输入办理订单备注"
class="mt-2"
/>
<p v-else class="mt-2 text-neutral-700">{{ order.processRemark || '-' }}</p>
</div>
<div v-if="!canEditCompleteInfo">
<label class="text-neutral-900 text-sm">办结登记时间</label>
<p class="mt-2 text-neutral-700">{{ order.completeTime || '-' }}</p>
</div>
</div>
</el-card>
<!-- 酬金信息区 -->
<el-card v-if="order.businessStatus !== '待办理'" class="p-6">
<h3 class="text-neutral-900 font-bold mb-4">酬金信息</h3>
<div class="space-y-5">
<!-- 预计酬金金额 -->
<div>
<label class="text-neutral-500 text-sm">预计酬金金额</label>
<p class="mt-2 text-2xl text-neutral-900">
¥{{ order.estimatedReward?.toFixed(2) || '0.00' }}
</p>
</div>
<!-- 实际结酬金额 -->
<div>
<label class="text-neutral-900 text-sm">
实际结酬金额{{ canEditReward && order.reviewStatus !== '待审核' ? '(选填)' : '' }}
</label>
<el-input
v-if="canEditReward && order.reviewStatus !== '待审核'"
v-model="actualReward"
placeholder="请输入实际结酬金额"
class="mt-2"
>
<template #prepend>¥</template>
</el-input>
<p v-else class="mt-2 text-2xl text-success">
¥{{ order.actualReward?.toFixed(2) || '0.00' }}
</p>
</div>
<!-- 审核通过时间 -->
<div v-if="order.reviewStatus === '审核通过' && order.paymentTime">
<label class="text-neutral-500 text-sm">审核通过时间</label>
<p class="mt-2 text-neutral-900">{{ order.paymentTime }}</p>
</div>
<!-- 审核驳回原因 -->
<div v-if="order.rejectReason">
<label class="text-error text-sm">审核驳回原因</label>
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded">
<p class="text-sm text-orange-900">{{ order.rejectReason }}</p>
</div>
</div>
</div>
</el-card>
</div>
</div>
<!-- 审核驳回对话框 -->
<el-dialog
v-model="isRejectDialogOpen"
title="审核驳回"
width="500px"
class="reject-dialog"
>
<div>
<el-form>
<el-form-item label="驳回原因" required>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请输入驳回原因"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<el-button
@click="isRejectDialogOpen = false"
class="border-neutral-300"
>
取消
</el-button>
<el-button
type="primary"
@click="handleReject"
class="bg-brand-primary"
>
确认驳回
</el-button>
</div>
</template>
</el-dialog>
<!-- 关闭订单对话框 -->
<el-dialog
v-model="isCloseDialogOpen"
title="关闭订单"
width="500px"
class="close-order-dialog"
>
<div>
<el-form>
<el-form-item label="关闭原因" required>
<el-input
v-model="closeReason"
type="textarea"
:rows="4"
placeholder="请输入关闭原因"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<el-button
@click="isCloseDialogOpen = false"
class="border-neutral-300"
>
取消
</el-button>
<el-button
type="primary"
@click="handleClose"
class="bg-brand-primary"
>
确认关闭
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Check, X } from 'lucide-vue-next'
import { ArrowDown } from '@element-plus/icons-vue'
// 类型定义
type BusinessStatus = '待办理' | '办理成功' | '已关闭'
type ReviewStatus = '待审核' | '审核通过' | '审核驳回'
interface OperationLog {
id: string
time: string
operator: string
action: string
details: string
}
interface Order {
id: string
registerPersonPhone: string
registerPersonName: string
customerPhone: string
businessName: string
registerTime: string
businessStatus: BusinessStatus
reviewStatus?: ReviewStatus
estimatedReward?: number
actualReward?: number
crmOrderNumber?: string
completeTime?: string
paymentTime?: string
rejectReason?: string
closeReason?: string
remarks?: string
processRemark?: string
operationLogs?: OperationLog[]
}
interface BusinessRule {
id: string
businessCode: string
businessName: string
estimatedReward: number
status: string
createTime: string
updateTime?: string
}
interface OrderDetailProps {
order: Order
businessRules: BusinessRule[]
currentUser: { username: string; role: 'admin' | 'viewer' } | null
}
// Props
const props = defineProps<OrderDetailProps>()
// Emits
const emit = defineEmits<{
update: [updates: Partial<Order>]
back: []
}>()
// 响应式数据
const crmOrderNumber = ref(props.order.crmOrderNumber || '')
const processRemark = ref(props.order.processRemark || '')
const actualReward = ref(
props.order.actualReward !== undefined ? String(props.order.actualReward) : ''
)
// 对话框状态
const isRejectDialogOpen = ref(false)
const rejectReason = ref('')
const isCloseDialogOpen = ref(false)
const closeReason = ref('')
// 计算属性
const canEditCompleteInfo = computed(() => {
return (props.order.businessStatus === '待办理' || props.order.businessStatus === '办理成功') &&
props.order.reviewStatus !== '审核通过' &&
props.order.reviewStatus !== '待审核'
})
const canEditReward = computed(() => {
return (props.order.businessStatus === '待办理' || props.order.businessStatus === '办理成功') &&
props.order.reviewStatus !== '审核通过'
})
// 方法
// 状态样式映射 - 使用浅色背景样式(与订单监控页保持一致)
const getBusinessStatusClass = (status: BusinessStatus) => {
const statusMap = {
'待办理': 'bg-blue-100 text-blue-800',
'办理成功': 'bg-green-100 text-green-800',
'已关闭': 'bg-gray-100 text-gray-800'
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
const getReviewStatusClass = (status: ReviewStatus) => {
const statusMap = {
'待审核': 'bg-purple-100 text-purple-800',
'审核通过': 'bg-green-100 text-green-800',
'审核驳回': 'bg-orange-100 text-orange-800'
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
// 待办理状态提交审核
const handleSaveCompleteInfo = () => {
if (!crmOrderNumber.value.trim()) {
ElMessage.error('请填写CRM订单编号')
return
}
const currentTime = new Date().toLocaleString('zh-CN')
// 自动关联业务规则,获取预计酬金
const businessRule = props.businessRules.find(rule =>
rule.businessName === props.order.businessName && rule.status === '生效中'
)
// 实际发放酬金为选填项,未填写则使用预计酬金
let finalAmount = businessRule?.estimatedReward || 0
if (actualReward.value && actualReward.value.trim()) {
const amount = parseFloat(actualReward.value)
if (isNaN(amount) || amount < 0) {
ElMessage.error('请输入有效的实际发放酬金')
return
}
finalAmount = amount
}
const newLog = {
id: `LOG${Date.now()}`,
time: currentTime,
operator: '管理员',
action: '提交审核',
details: `CRM订单编号:${crmOrderNumber.value},提交给主管理员审核,实际发放酬金:¥${finalAmount.toFixed(2)}`
}
emit('update', {
crmOrderNumber: crmOrderNumber.value,
processRemark: processRemark.value.trim(),
completeTime: currentTime,
businessStatus: '办理成功',
reviewStatus: '待审核',
estimatedReward: businessRule?.estimatedReward,
actualReward: finalAmount,
rejectReason: undefined,
operationLogs: [...(props.order.operationLogs || []), newLog]
})
ElMessage.success('已提交审核,等待主管理员审核')
setTimeout(() => {
emit('back')
}, 1000)
}
// 提交审核(办理成功/审核驳回 → 待审核)
const handleSubmitReview = () => {
if (!crmOrderNumber.value.trim()) {
ElMessage.error('请填写CRM订单编号')
return
}
let finalAmount = props.order.estimatedReward || 0
if (actualReward.value && actualReward.value.trim()) {
const amount = parseFloat(actualReward.value)
if (isNaN(amount) || amount < 0) {
ElMessage.error('请输入有效的实际发放酬金')
return
}
finalAmount = amount
}
const newLog = {
id: `LOG${Date.now()}`,
time: new Date().toLocaleString('zh-CN'),
operator: '管理员',
action: '提交审核',
details: `CRM订单编号:${crmOrderNumber.value},提交给主管理员审核,实际发放酬金:¥${finalAmount.toFixed(2)}`
}
emit('update', {
crmOrderNumber: crmOrderNumber.value,
processRemark: processRemark.value.trim(),
reviewStatus: '待审核',
actualReward: finalAmount,
rejectReason: undefined,
operationLogs: [...(props.order.operationLogs || []), newLog]
})
ElMessage.success('已提交审核,等待主管理员审核')
setTimeout(() => {
emit('back')
}, 1000)
}
// 审核通过
const handleApprove = () => {
const currentTime = new Date().toLocaleString('zh-CN')
const newLog = {
id: `LOG${Date.now()}`,
time: currentTime,
operator: '主管理员',
action: '审核通过',
details: `主管审核通过,金额:¥${props.order.actualReward?.toFixed(2)}`
}
emit('update', {
reviewStatus: '审核通过',
paymentTime: currentTime,
operationLogs: [...(props.order.operationLogs || []), newLog]
})
ElMessage.success('审核通过!')
setTimeout(() => {
emit('back')
}, 1000)
}
// 审核驳回
const handleReject = () => {
if (!rejectReason.value.trim()) {
ElMessage.error('请填写驳回原因')
return
}
const newLog = {
id: `LOG${Date.now()}`,
time: new Date().toLocaleString('zh-CN'),
operator: '主管理员',
action: '审核驳回',
details: `驳回原因:${rejectReason.value}`
}
emit('update', {
reviewStatus: '审核驳回',
rejectReason: rejectReason.value,
operationLogs: [...(props.order.operationLogs || []), newLog]
})
ElMessage.success('已驳回订单')
isRejectDialogOpen.value = false
rejectReason.value = ''
setTimeout(() => {
emit('back')
}, 1000)
}
// 关闭订单
const handleClose = () => {
if (!closeReason.value.trim()) {
ElMessage.error('请填写关闭原因')
return
}
const newLog = {
id: `LOG${Date.now()}`,
time: new Date().toLocaleString('zh-CN'),
operator: '管理员',
action: '关闭订单',
details: `关闭原因:${closeReason.value}`
}
emit('update', {
businessStatus: '已关闭',
closeReason: closeReason.value,
operationLogs: [...(props.order.operationLogs || []), newLog]
})
ElMessage.success('订单已关闭')
isCloseDialogOpen.value = false
closeReason.value = ''
setTimeout(() => {
emit('back')
}, 1000)
}
const handleAuditCommand = (command: string) => {
if (command === 'approve') {
handleApprove()
} else if (command === 'reject') {
isRejectDialogOpen.value = true
}
}
</script>
<style scoped>
/* 自定义样式 */
:deep(.el-card) {
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
:deep(.el-input__wrapper) {
border-radius: 6px;
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
:deep(.el-textarea__inner) {
border-radius: 6px;
background-color: #f3f3f5 !important;
border: none !important;
box-shadow: none !important;
}
/* 状态标签样式已统一使用 span + CSS 类,无需额外样式 */
</style>
<template>
<div class="space-y-6">
<!-- 页面标题和操作区 -->
<div class="flex items-center justify-between">
<h3 class="text-neutral-900 text-[20px] font-bold">登记订单管理</h3>
<div class="flex">
<el-button class="h-10 px-4" @click="handleExport">
<Download class="h-4 w-4 mr-1" />
导出数据
</el-button>
<el-button
type="primary"
:disabled="selectedOrderIds.length === 0"
class="h-10 pr-4 pl-0"
@click="handleBatchReview"
>
<Check class="h-4 w-4 mr-1" />
批量审核{{ selectedOrderIds.length > 0 ? ` (${selectedOrderIds.length})` : '' }}
</el-button>
<el-button
type="primary"
class="h-10 pr-4 pl-0"
@click="handleBatchModify"
>
<Edit3 class="h-4 w-4 mr-1" />
批量修改
</el-button>
</div>
</div>
<!-- 筛选和搜索区域 -->
<el-card>
<div class="flex items-center gap-2 w-full p-6">
<!-- 搜索框 -->
<div class="relative flex-[2]">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500 z-10" />
<el-input
v-model="searchKeyword"
placeholder="输入订单ID、CRM订单编号、能人手机号、客户号码搜索"
class="search-input text-ellipsis-input"
clearable
/>
</div>
<!-- 查询时间 -->
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="flex-1"
placeholder="查询时间"
/>
<!-- 全部业务状态 -->
<el-select v-model="businessStatusFilter" placeholder="全部业务状态" class="flex-1 bg-gray-100">
<el-option label="全部业务状态" value="all" />
<el-option label="待办理" value="待办理" />
<el-option label="办理成功" value="办理成功" />
<el-option label="已关闭" value="已关闭" />
</el-select>
<!-- 全部审核状态 -->
<el-select v-model="reviewStatusFilter" placeholder="全部审核状态" class="flex-1 bg-gray-100">
<el-option label="全部审核状态" value="all" />
<el-option label="待审核" value="待审核" />
<el-option label="审核通过" value="审核通过" />
<el-option label="审核驳回" value="审核驳回" />
</el-select>
<!-- 全部业务 -->
<el-select v-model="businessNameFilter" placeholder="全部业务" class="flex-1 bg-gray-100">
<el-option label="全部业务" value="all" />
<el-option
v-for="businessName in uniqueBusinessNames"
:key="businessName"
:label="businessName"
:value="businessName"
/>
</el-select>
<!-- 查询和重置按钮 -->
<el-button type="primary" @click="handleSearch" class="h-10 px-4 shrink-0">
查询
</el-button>
<el-button @click="handleReset" class="h-10 pr-4 pl-0 shrink-0">
重置
</el-button>
</div>
</el-card>
<!-- 数据表格区 -->
<el-card>
<div class="table-container">
<el-table
:data="paginatedOrders"
class="order-table"
stripe
border
@selection-change="handleSelectionChange"
>
<!-- 选择列 -->
<el-table-column type="selection" width="50" :selectable="isRowSelectable" />
<!-- 订单ID -->
<el-table-column prop="id" label="订单ID" width="140" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-mono text-[14px]">{{ row.id }}</span>
</template>
</el-table-column>
<!-- 能人 -->
<el-table-column prop="registerPersonName" label="能人" width="70" />
<!-- 能人手机号 -->
<el-table-column prop="registerPersonPhone" label="能人手机号" width="110" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-mono">{{ row.registerPersonPhone }}</span>
</template>
</el-table-column>
<!-- 客户号码 -->
<el-table-column prop="customerPhone" label="客户号码" width="110" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-mono">{{ row.customerPhone }}</span>
</template>
</el-table-column>
<!-- 业务名称 -->
<el-table-column prop="businessName" label="业务名称" min-width="100" />
<!-- 登记时间 -->
<el-table-column prop="registerTime" label="登记时间" width="140" show-overflow-tooltip />
<!-- 预计酬金 -->
<el-table-column prop="estimatedReward" label="预计酬金" width="90" align="right">
<template #default="{ row }">
<span class="font-mono">¥{{ row.estimatedReward.toFixed(2) }}</span>
</template>
</el-table-column>
<!-- 实际酬金 -->
<el-table-column prop="actualReward" label="实际酬金" width="90" align="right">
<template #default="{ row }">
<span v-if="row.actualReward" class="font-mono">¥{{ row.actualReward.toFixed(2) }}</span>
<span v-else class="text-neutral-400">-</span>
</template>
</el-table-column>
<!-- CRM订单编号 -->
<el-table-column prop="crmOrderNumber" label="CRM订单编号" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.crmOrderNumber" class="font-mono text-[14px]">{{ row.crmOrderNumber }}</span>
<span v-else class="text-neutral-400">-</span>
</template>
</el-table-column>
<!-- 办结登记时间 -->
<el-table-column prop="completeTime" label="办结登记时间" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.completeTime">{{ row.completeTime }}</span>
<span v-else class="text-neutral-400">-</span>
</template>
</el-table-column>
<!-- 业务状态 -->
<el-table-column prop="businessStatus" label="业务状态" width="90" fixed="right">
<template #default="{ row }">
<span
:class="getBusinessStatusClass(row.businessStatus)"
class="px-2 py-1 rounded text-xs font-medium"
>
{{ row.businessStatus }}
</span>
</template>
</el-table-column>
<!-- 审核状态 -->
<el-table-column prop="reviewStatus" label="审核状态" width="90" fixed="right">
<template #default="{ row }">
<span
v-if="row.reviewStatus"
:class="getReviewStatusClass(row.reviewStatus)"
class="px-2 py-1 rounded text-xs font-medium"
>
{{ row.reviewStatus }}
</span>
<span v-else class="text-neutral-400">-</span>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex gap-1">
<el-button
type="primary"
link
size="small"
@click="handleViewDetail(row)"
>
详情
</el-button>
<el-button
v-if="row.reviewStatus === '待审核'"
type="primary"
link
size="small"
@click="handleApprove(row)"
>
审核
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="flex justify-between items-center mt-4 py-3 px-6">
<div class="text-sm text-neutral-600">
{{ filteredOrders.length }} 条数据,当前第 {{ currentPage }}/{{ totalPages }}
<span v-if="selectedOrderIds.length > 0" class="ml-2">
,已选择 {{ selectedOrderIds.length }}
</span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-neutral-600">每页条数</span>
<el-select v-model="pageSize" class="w-[180px]" size="small" @change="handlePageSizeChange">
<el-option label="10 条/页" :value="10" />
<el-option label="20 条/页" :value="20" />
<el-option label="50 条/页" :value="50" />
<el-option label="100 条/页" :value="100" />
</el-select>
</div>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredOrders.length"
layout="prev, pager, next"
small
@current-change="handleCurrentChange"
/>
</div>
</div>
</el-card>
<!-- 审核对话框 -->
<el-dialog v-model="isReviewDialogOpen" title="订单审核" width="600px" class="single-review-dialog">
<div v-if="reviewingOrder" class="space-y-4">
<div class="grid grid-cols-2 gap-6">
<!-- 左列 -->
<div class="space-y-4">
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">订单ID</label>
<p class="text-sm text-neutral-900 font-mono" style="margin-left: 16px;">{{ reviewingOrder.id }}</p>
</div>
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">能人</label>
<p class="text-sm text-neutral-900" style="margin-left: 16px;">{{ reviewingOrder.registerPersonName }}</p>
</div>
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">CRM订单编号</label>
<p class="text-sm text-neutral-900 font-mono" style="margin-left: 16px;">{{ reviewingOrder.crmOrderNumber || '-' }}</p>
</div>
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">实际酬金</label>
<p class="text-sm font-mono text-green-600" style="margin-left: 16px;">¥{{ (reviewingOrder.actualReward || reviewingOrder.estimatedReward).toFixed(2) }}</p>
</div>
</div>
<!-- 右列 -->
<div class="space-y-4">
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">业务名称</label>
<p class="text-sm text-neutral-900" style="margin-left: 16px;">{{ reviewingOrder.businessName }}</p>
</div>
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">客户号码</label>
<p class="text-sm text-neutral-900 font-mono" style="margin-left: 16px;">{{ reviewingOrder.customerPhone }}</p>
</div>
<div class="flex items-center">
<label class="text-sm text-neutral-500 shrink-0">预计酬金</label>
<p class="text-sm font-mono text-blue-600" style="margin-left: 16px;">¥{{ reviewingOrder.estimatedReward.toFixed(2) }}</p>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button
type="danger"
plain
@click="handleReject"
>
<X class="h-4 w-4 mr-1" />
驳回
</el-button>
<el-button
type="primary"
@click="handleConfirmApprove"
class="bg-brand-primary"
>
<Check class="h-4 w-4 mr-1" />
通过
</el-button>
</div>
</template>
</el-dialog>
<!-- 批量审核对话框 -->
<el-dialog v-model="isBatchReviewDialogOpen" title="批量审核订单" width="600px" class="batch-review-dialog">
<div class="space-y-4">
<div class="bg-blue-50 p-4 rounded-lg">
<p class="text-sm text-neutral-600 mb-2">待审核订单列表({{ batchReviewOrders.length }}</p>
<div class="max-h-60 overflow-y-auto space-y-2">
<div
v-for="order in batchReviewOrders"
:key="order.id"
class="bg-white p-3 rounded border border-blue-100"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-neutral-900">订单ID: {{ order.id }}</p>
<p class="text-xs text-neutral-600 mt-1">
业务类型:{{ order.businessName }} |
预计酬金:¥{{ order.estimatedReward.toFixed(2) }}
<span v-if="order.actualReward">
| 实际酬金:¥{{ order.actualReward.toFixed(2) }}
</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 驳回原因输入框 -->
<div v-if="showBatchRejectReason" class="space-y-2">
<label class="text-neutral-700 text-sm">驳回原因 <span class="text-error">*</span></label>
<el-input
v-model="batchRejectReason"
type="textarea"
:rows="4"
placeholder="请输入驳回原因"
maxlength="200"
show-word-limit
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button
type="danger"
plain
@click="showBatchRejectReason = true"
v-if="!showBatchRejectReason"
>
<X class="h-4 w-4 mr-1" />
审核驳回
</el-button>
<el-button
type="danger"
plain
@click="handleConfirmBatchReject"
v-if="showBatchRejectReason"
>
<X class="h-4 w-4 mr-1" />
确认驳回
</el-button>
<el-button type="primary" @click="handleConfirmBatchApprove">
<Check class="h-4 w-4 mr-1" />
审核通过
</el-button>
</div>
</template>
</el-dialog>
<!-- 批量修改对话框 -->
<el-dialog v-model="isBatchModifyDialogOpen" title="批量修改酬金" width="720px" class="batch-modify-dialog">
<div class="space-y-6">
<!-- 第一步:下载模板 -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6">
<div class="text-lg font-medium text-gray-900 mb-2">第一步:下载模板</div>
<div class="text-sm text-gray-600 mb-4">
请先下载模板文件,按照模板格式填写数据。
</div>
<el-button type="primary" size="large" @click="downloadBatchTemplate">
<Download class="h-4 w-4 mr-2" />
下载模板
</el-button>
</div>
<!-- 第二步:上传文件 -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6">
<div class="mb-4">
<div class="text-lg font-medium text-gray-900 mb-2">第二步:上传文件</div>
<div class="text-sm text-gray-600">
上传填好的文件,系统将自动解析并验证数据。支持 xls/xlsx 格式,单次最多100条。
</div>
</div>
<div class="space-y-4">
<el-upload
class="w-full"
:show-file-list="false"
accept=".xls,.xlsx"
:auto-upload="false"
:on-change="handleBatchFileChange"
>
<el-button type="primary" size="large">
<Upload class="h-4 w-4 mr-2" />
选择文件
</el-button>
</el-upload>
<!-- 文件信息显示 -->
<div v-if="batchFileName" class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="text-sm text-[#3b82f6] font-mono">{{ batchFileName }}</div>
<el-button
size="small"
text
class="text-gray-600 hover:text-gray-800"
@click="clearSelectedFile"
>
重新选择
</el-button>
</div>
</div>
</div>
</div>
<div v-if="batchParseDone" class="space-y-3">
<div class="text-sm">
解析结果:
<span class="text-green-600 font-medium">有效 {{ batchValidRows.length }}</span>
<span class="mx-2 text-neutral-400">|</span>
<span class="text-red-600 font-medium">无效 {{ batchInvalidRows.length }}</span>
</div>
<el-table :data="batchPreviewRows" height="260" border stripe>
<el-table-column prop="orderId" label="订单ID" min-width="160" />
<el-table-column prop="currentReward" label="当前酬金" width="100" align="right">
<template #default="{ row }">
<span class="font-mono">¥{{ Number(row.currentReward).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="newReward" label="新酬金" width="100" align="right">
<template #default="{ row }">
<span class="font-mono">¥{{ Number(row.newReward).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column prop="status" label="校验" width="80">
<template #default="{ row }">
<el-tag v-if="row.status === 'OK'" type="success" size="small">有效</el-tag>
<el-tag v-else type="danger" size="small">无效</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="180" show-overflow-tooltip />
</el-table>
</div>
</div>
<template #footer>
<el-button @click="closeBatchDialog">取消</el-button>
<el-button
type="primary"
:disabled="!batchParseDone || batchValidRows.length === 0"
@click="openVerifyDialog"
>
提交审核({{ batchValidRows.length }}
</el-button>
</template>
</el-dialog>
<!-- 审核驳回对话框 -->
<el-dialog
v-model="isRejectDialogOpen"
title="审核驳回"
width="500px"
class="reject-dialog"
>
<div>
<el-form>
<el-form-item label="驳回原因" required>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请输入驳回原因"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<el-button
@click="isRejectDialogOpen = false"
class="border-neutral-300"
>
取消
</el-button>
<el-button
type="primary"
@click="handleConfirmReject"
class="bg-brand-primary"
>
确认驳回
</el-button>
</div>
</template>
</el-dialog>
<!-- 验证码对话框 -->
<el-dialog v-model="isVerifyDialogOpen" title="安全验证" width="420px" class="verify-dialog">
<div class="space-y-3">
<p class="text-sm text-neutral-600">请输入6位验证码以确认批量修改。</p>
<el-input
v-model="verifyCode"
maxlength="6"
placeholder="输入验证码(演示用:123456)"
class="w-full"
/>
</div>
<template #footer>
<el-button @click="isVerifyDialogOpen = false">取消</el-button>
<el-button type="primary" :disabled="verifyCode.length !== 6" @click="applyBatchModify">
验证并执行
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Check, Edit3, Search, Upload, X } from 'lucide-vue-next'
// Props
interface Props {
orders?: any[]
onViewOrderDetail?: (order: any) => void
onUpdateOrder?: (updates: any) => void
}
const props = withDefaults(defineProps<Props>(), {
orders: () => []
})
// Emits
const emit = defineEmits(['view-order-detail', 'update-order', 'orders-update'])
// 响应式数据
const searchKeyword = ref('')
const dateRange = ref<[string, string] | null>(null)
const businessStatusFilter = ref('all')
const reviewStatusFilter = ref('all')
const businessNameFilter = ref('all')
const selectedOrderIds = ref<string[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
// 对话框状态
const isReviewDialogOpen = ref(false)
const reviewingOrder = ref<any>(null)
const isBatchReviewDialogOpen = ref(false)
const batchReviewOrders = ref<any[]>([])
const batchRejectReason = ref('')
const showBatchRejectReason = ref(false)
const isRejectDialogOpen = ref(false)
const rejectReason = ref('')
// 批量修改状态
const isBatchModifyDialogOpen = ref(false)
const currentBatchStep = ref(0)
const batchFileName = ref('')
const batchParseDone = ref(false)
const batchPreviewRows = ref<any[]>([])
const batchValidRows = ref<any[]>([])
const batchInvalidRows = ref<any[]>([])
const isVerifyDialogOpen = ref(false)
const verifyCode = ref('')
// 订单数据
const orders = ref<any[]>([])
// 类型定义
type BusinessStatus = '待办理' | '办理成功' | '已关闭'
type ReviewStatus = '待审核' | '审核通过' | '审核驳回'
interface OperationLog {
id: string
time: string
operator: string
action: string
details: string
}
interface Order {
id: string
registerPersonPhone: string
registerPersonName: string
customerPhone: string
businessName: string
registerTime: string
businessStatus: BusinessStatus
reviewStatus?: ReviewStatus
estimatedReward: number
actualReward?: number
crmOrderNumber?: string
completeTime?: string
paymentTime?: string
rejectReason?: string
closeReason?: string
remarks?: string
processRemark?: string
operationLogs?: OperationLog[]
}
// 生成模拟数据
const generateMockOrders = (): Order[] => {
const mockOrders: Order[] = []
const names = [
'王芳', '李静', '刘备', '关羽', '张飞', '赵云', '马超', '黄忠', '魏延', '姜维',
'陈明', '李娜', '王强', '赵丽', '孙杰', '周芳', '吴军', '郑敏', '刘洋', '黄涛',
'杨雪', '朱鹏', '徐霞', '何亮', '高华', '林静', '梁伟', '宋佳', '韩冰', '唐磊',
'冯琳', '许刚', '曹丽', '蔡勇', '彭燕', '马强', '卢敏', '陆伟', '姚娟', '钱浩',
'秦霞', '任杰', '沈芳', '汤明', '田丽', '万强', '温娜', '夏伟', '谢静', '熊勇'
]
const businesses = ['5G套餐办理', '宽带新装', '话费充值', '流量包办理']
const businessRewards = { '5G套餐办理': 50, '宽带新装': 80, '话费充值': 30, '流量包办理': 40 }
const businessStatuses: BusinessStatus[] = ['待办理', '办理成功', '已关闭']
const reviewStatuses: ReviewStatus[] = ['待审核', '审核通过', '审核驳回']
for (let i = 0; i < 120; i++) {
const dayOffset = Math.floor(i / 10)
const date = new Date(2025, 9, 28 - dayOffset)
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const orderId = `ORD${dateStr.replace(/-/g, '')}${String(i + 1).padStart(3, '0')}`
const name = names[i % names.length] as string
const phone = `138${String(10000 + i).padStart(8, '0')}`
const customerPhone = `139${String(20000 + i).padStart(8, '0')}`
const businessName = businesses[i % businesses.length] as string
const estimatedReward = businessRewards[businessName as keyof typeof businessRewards]
// 根据索引分配不同的状态
const statusType = i % 6
let businessStatus: BusinessStatus
let reviewStatus: ReviewStatus | undefined
let actualReward: number | undefined
let crmOrderNumber: string | undefined
let completeTime: string | undefined
let paymentTime: string | undefined
let rejectReason: string | undefined
let closeReason: string | undefined
const operationLogs: OperationLog[] = []
if (statusType === 0) {
// 待办理 (约17%)
businessStatus = '待办理'
} else if (statusType === 1) {
// 办理成功+待审核 (约17%)
businessStatus = '办理成功'
reviewStatus = '待审核'
actualReward = estimatedReward + (Math.random() > 0.5 ? Math.floor(Math.random() * 10) : -Math.floor(Math.random() * 5))
crmOrderNumber = `CRM${dateStr.replace(/-/g, '')}${String(i + 1000).padStart(4, '0')}`
completeTime = `${dateStr} ${String(14 + (i % 8)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}`
} else if (statusType === 2) {
// 办理成功+审核通过 (约17%)
businessStatus = '办理成功'
reviewStatus = '审核通过'
actualReward = estimatedReward + (Math.random() > 0.7 ? Math.floor(Math.random() * 5) : 0)
crmOrderNumber = `CRM${dateStr.replace(/-/g, '')}${String(i + 1000).padStart(4, '0')}`
completeTime = `${dateStr} ${String(14 + (i % 8)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}`
paymentTime = `${dateStr} ${String(16 + (i % 6)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}`
} else if (statusType === 3) {
// 办理成功+审核驳回 (约17%)
businessStatus = '办理成功'
reviewStatus = '审核驳回'
actualReward = estimatedReward + (i % 3 === 0 ? 25 : i % 3 === 1 ? -15 : 10)
crmOrderNumber = `CRM${dateStr.replace(/-/g, '')}${String(i + 1000).padStart(4, '0')}`
completeTime = `${dateStr} ${String(14 + (i % 8)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}`
rejectReason = ['金额与实际不符', 'CRM订单号有误', '缺少必要凭证', '客户信息不匹配'][i % 4]
} else if (statusType === 4) {
// 已关闭 (约17%)
businessStatus = '已关闭'
actualReward = estimatedReward
crmOrderNumber = `CRM${dateStr.replace(/-/g, '')}${String(i + 1000).padStart(4, '0')}`
completeTime = `${dateStr} ${String(14 + (i % 8)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}`
closeReason = ['客户取消办理', '业务规则变更', '重复订单', '客户联系不上'][i % 4]
} else {
// 办理成功无审核状态 (约15%)
businessStatus = '办理成功'
actualReward = estimatedReward
crmOrderNumber = `CRM${dateStr.replace(/-/g, '')}${String(i + 1000).padStart(4, '0')}`
completeTime = `${dateStr} ${String(14 + (i % 8)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}`
}
// 添加基础操作日志
operationLogs.push({
id: `LOG${orderId}_001`,
time: `${dateStr} ${String(9 + (i % 4)).padStart(2, '0')}:${String((i * 13) % 60).padStart(2, '0')}`,
operator: name,
action: '登记订单',
details: `能人登记新订单,等待办理。业务类型:${businessName}`
})
// 根据状态添加相应的操作日志
if (businessStatus === '办理成功') {
operationLogs.push({
id: `LOG${orderId}_002`,
time: completeTime!,
operator: '管理员',
action: '保存办结信息',
details: `CRM订单编号:${crmOrderNumber},移动人员成功办理业务`
})
if (reviewStatus === '审核通过') {
operationLogs.push({
id: `LOG${orderId}_003`,
time: `${dateStr} ${String(16 + (i % 6)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}`,
operator: '主管理员',
action: '审核通过',
details: `主管审核通过,金额:¥${actualReward?.toFixed(2)}`
})
} else if (reviewStatus === '审核驳回') {
const rejectReasons = ['金额与实际不符', 'CRM订单号有误', '缺少必要凭证', '客户信息不匹配']
const rejectReason = rejectReasons[i % rejectReasons.length]
operationLogs.push({
id: `LOG${orderId}_003`,
time: `${dateStr} ${String(16 + (i % 6)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}`,
operator: '主管理员',
action: '审核驳回',
details: `驳回原因:${rejectReason}`
})
}
} else if (businessStatus === '已关闭') {
const closeReasons = ['客户取消办理', '业务规则变更', '重复订单', '客户联系不上']
const closeReason = closeReasons[i % closeReasons.length]
operationLogs.push({
id: `LOG${orderId}_002`,
time: completeTime!,
operator: '管理员',
action: '关闭订单',
details: `关闭原因:${closeReason}`
})
}
mockOrders.push({
id: orderId,
registerPersonPhone: phone,
registerPersonName: name,
customerPhone: customerPhone,
businessName: businessName,
registerTime: `${dateStr} ${String(9 + (i % 4)).padStart(2, '0')}:${String((i * 13) % 60).padStart(2, '0')}`,
businessStatus: businessStatus,
reviewStatus: reviewStatus,
estimatedReward: estimatedReward,
actualReward: actualReward,
crmOrderNumber: crmOrderNumber,
completeTime: completeTime,
paymentTime: reviewStatus === '审核通过' ? `${dateStr} ${String(16 + (i % 6)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}` : undefined,
rejectReason: reviewStatus === '审核驳回' ? ['金额与实际不符', 'CRM订单号有误', '缺少必要凭证', '客户信息不匹配'][i % 4] : undefined,
closeReason: businessStatus === '已关闭' ? ['客户取消办理', '业务规则变更', '重复订单', '客户联系不上'][i % 4] : undefined,
remarks: i % 8 === 0 ? '客户要求尽快办理' : i % 12 === 0 ? '客户地址偏远' : undefined,
processRemark: businessStatus !== '待办理' ? `办理备注信息${i + 1}` : undefined,
operationLogs: operationLogs
})
}
return mockOrders
}
// 计算属性
const uniqueBusinessNames = computed(() => {
return Array.from(new Set(orders.value.map(order => order.businessName))).sort()
})
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// 搜索关键词匹配
const matchSearch = !searchKeyword.value ||
order.id.includes(searchKeyword.value) ||
order.crmOrderNumber?.includes(searchKeyword.value) ||
order.registerPersonPhone.includes(searchKeyword.value) ||
order.customerPhone.includes(searchKeyword.value)
// 业务状态匹配
const matchBusinessStatus = businessStatusFilter.value === 'all' || order.businessStatus === businessStatusFilter.value
// 审核状态匹配
const matchReviewStatus = reviewStatusFilter.value === 'all' || order.reviewStatus === reviewStatusFilter.value
// 业务名称匹配
const matchBusinessName = businessNameFilter.value === 'all' || order.businessName === businessNameFilter.value
// 日期范围匹配
let matchDate = true
if (dateRange.value && dateRange.value.length === 2) {
const orderDate = new Date(order.registerTime.split(' ')[0])
const startDate = new Date(dateRange.value[0])
const endDate = new Date(dateRange.value[1])
matchDate = orderDate >= startDate && orderDate <= endDate
}
return matchSearch && matchBusinessStatus && matchReviewStatus && matchBusinessName && matchDate
})
})
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize.value)
})
const paginatedOrders = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredOrders.value.slice(start, end)
})
// 状态样式映射 - 使用浅色背景样式
const getBusinessStatusClass = (status: BusinessStatus) => {
const statusMap = {
'待办理': 'bg-blue-100 text-blue-800',
'办理成功': 'bg-green-100 text-green-800',
'已关闭': 'bg-gray-100 text-gray-800'
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
const getReviewStatusClass = (status: ReviewStatus) => {
const statusMap = {
'待审核': 'bg-purple-100 text-purple-800',
'审核通过': 'bg-green-100 text-green-800',
'审核驳回': 'bg-orange-100 text-orange-800'
}
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
// 判断行是否可选择(只有待审核的订单才能被选择)
const isRowSelectable = (row: any) => {
return row.reviewStatus === '待审核'
}
// 事件处理
const handleSearch = () => {
currentPage.value = 1
ElMessage.success('查询完成')
}
const handleReset = () => {
searchKeyword.value = ''
dateRange.value = null
businessStatusFilter.value = 'all'
reviewStatusFilter.value = 'all'
businessNameFilter.value = 'all'
currentPage.value = 1
ElMessage.success('已重置筛选条件')
}
const handleSelectionChange = (selection: any[]) => {
selectedOrderIds.value = selection.map(item => item.id)
}
const handleExport = () => {
ElMessage.success('导出功能开发中')
}
const handleBatchReview = () => {
if (selectedOrderIds.value.length === 0) {
ElMessage.error('请选择要审核的订单')
return
}
// 确保只对待审核的订单进行批量审核
const pendingOrders = orders.value.filter(order =>
selectedOrderIds.value.includes(order.id) && order.reviewStatus === '待审核'
)
if (pendingOrders.length === 0) {
ElMessage.error('所选订单中没有待审核的订单')
return
}
// 设置批量审核的订单列表并打开对话框
batchReviewOrders.value = pendingOrders
isBatchReviewDialogOpen.value = true
}
const handleBatchModify = () => {
resetBatchState()
isBatchModifyDialogOpen.value = true
}
const handleViewDetail = (order: any) => {
emit('view-order-detail', order)
}
const handleApprove = (order: any) => {
reviewingOrder.value = order
isReviewDialogOpen.value = true
}
const handleConfirmApprove = () => {
if (reviewingOrder.value) {
const currentTime = new Date().toLocaleString('zh-CN')
const order = orders.value.find(o => o.id === reviewingOrder.value.id)
if (order) {
// 更新订单状态
order.reviewStatus = '审核通过'
order.paymentTime = currentTime
// 添加操作日志
if (!order.operationLogs) {
order.operationLogs = []
}
order.operationLogs.push({
id: `LOG${order.id}_${Date.now()}`,
time: currentTime,
operator: '主管理员',
action: '审核通过',
details: `主管审核通过,金额:¥${order.actualReward?.toFixed(2) || order.estimatedReward.toFixed(2)}`
})
emit('orders-update', orders.value)
}
ElMessage.success(`订单 ${reviewingOrder.value.id} 审核通过`)
isReviewDialogOpen.value = false
reviewingOrder.value = null
}
}
const handleReject = () => {
if (!reviewingOrder.value) return
isRejectDialogOpen.value = true
}
const handleConfirmReject = () => {
if (!rejectReason.value.trim()) {
ElMessage.error('请填写驳回原因')
return
}
if (reviewingOrder.value) {
const currentTime = new Date().toLocaleString('zh-CN')
const order = orders.value.find(o => o.id === reviewingOrder.value.id)
if (order) {
// 更新订单状态
order.reviewStatus = '审核驳回'
order.rejectReason = rejectReason.value
// 添加操作日志
if (!order.operationLogs) {
order.operationLogs = []
}
order.operationLogs.push({
id: `LOG${order.id}_${Date.now()}`,
time: currentTime,
operator: '主管理员',
action: '审核驳回',
details: `驳回原因:${rejectReason.value}`
})
emit('orders-update', orders.value)
}
ElMessage.success(`订单 ${reviewingOrder.value.id} 已驳回`)
isReviewDialogOpen.value = false
isRejectDialogOpen.value = false
reviewingOrder.value = null
rejectReason.value = ''
}
}
const handleConfirmBatchReject = () => {
if (batchReviewOrders.value.length === 0) {
ElMessage.error('没有待审核的订单')
return
}
const currentTime = new Date().toLocaleString('zh-CN')
let rejectCount = 0
batchReviewOrders.value.forEach(order => {
const orderInList = orders.value.find(o => o.id === order.id)
if (orderInList && orderInList.reviewStatus === '待审核') {
orderInList.reviewStatus = '审核驳回'
orderInList.rejectReason = '批量审核驳回'
if (!orderInList.operationLogs) {
orderInList.operationLogs = []
}
orderInList.operationLogs.push({
id: `LOG${orderInList.id}_${Date.now()}_${Math.random()}`,
time: currentTime,
operator: '主管理员',
action: '审核驳回',
details: '批量审核驳回:未通过主管复核'
})
rejectCount++
}
})
selectedOrderIds.value = []
batchReviewOrders.value = []
isBatchReviewDialogOpen.value = false
batchRejectReason.value = ''
showBatchRejectReason.value = false
emit('orders-update', orders.value)
ElMessage.success(`成功批量驳回 ${rejectCount} 个订单`)
}
const handleConfirmBatchApprove = () => {
if (batchReviewOrders.value.length === 0) {
ElMessage.error('没有待审核的订单')
return
}
const currentTime = new Date().toLocaleString('zh-CN')
let successCount = 0
batchReviewOrders.value.forEach(order => {
const orderInList = orders.value.find(o => o.id === order.id)
if (orderInList && orderInList.reviewStatus === '待审核') {
// 更新订单状态
orderInList.reviewStatus = '审核通过'
orderInList.paymentTime = currentTime
// 添加操作日志
if (!orderInList.operationLogs) {
orderInList.operationLogs = []
}
orderInList.operationLogs.push({
id: `LOG${orderInList.id}_${Date.now()}_${Math.random()}`,
time: currentTime,
operator: '主管理员',
action: '审核通过',
details: `主管批量审核通过,金额:¥${orderInList.actualReward?.toFixed(2) || orderInList.estimatedReward.toFixed(2)}`
})
successCount++
}
})
// 清空选择
selectedOrderIds.value = []
batchReviewOrders.value = []
isBatchReviewDialogOpen.value = false
emit('orders-update', orders.value)
ElMessage.success(`成功批量审核通过 ${successCount} 个订单`)
}
const handlePageSizeChange = () => {
currentPage.value = 1
}
const handleCurrentChange = (page: number) => {
currentPage.value = page
}
// 批量修改:模板下载
const downloadBatchTemplate = () => {
const header = ['订单ID', '当前酬金', '新酬金', '备注']
const sample = [
['ORD20251107XXX', '50.00', '55.00', '调整酬金'],
['ORD20251107YYY', '30.00', '35.00', '补贴']
]
const lines = [header, ...sample].map(cols => cols.join(',')).join('\n')
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '批量修改模板.csv'
a.click()
URL.revokeObjectURL(url)
}
// 批量修改:重置状态
const resetBatchState = () => {
currentBatchStep.value = 0
batchFileName.value = ''
batchParseDone.value = false
batchPreviewRows.value = []
batchValidRows.value = []
batchInvalidRows.value = []
verifyCode.value = ''
}
// 清除选中的文件
const clearSelectedFile = () => {
batchFileName.value = ''
batchParseDone.value = false
batchPreviewRows.value = []
batchValidRows.value = []
batchInvalidRows.value = []
}
// CSV 简单解析
const parseCsv = (text: string): string[][] => {
// 简易 CSV:按行分割,再按逗号分割,不处理引号嵌套的复杂情况
return text
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => line.split(',').map(col => col.trim()))
}
// 校验并映射为预览行
const buildPreviewRows = (rows: string[][]) => {
const header = rows[0] || []
const body = rows.slice(1)
const getIndex = (name: string) => header.findIndex(h => h.replace(/\s/g, '') === name)
const idxOrderId = getIndex('订单ID')
const idxCurrent = getIndex('当前酬金')
const idxNew = getIndex('新酬金')
const idxRemark = getIndex('备注')
const preview: any[] = []
const valid: any[] = []
const invalid: any[] = []
if (idxOrderId === -1 || idxNew === -1) {
ElMessage.error('模板列缺失,请使用系统模板:需要包含 订单ID、新酬金')
return { preview, valid, invalid }
}
const maxCount = 100
if (body.length > maxCount) {
ElMessage.warning(`仅解析前 ${maxCount} 条记录,其余将忽略`)
}
const limitRows = body.slice(0, maxCount)
limitRows.forEach((cols, i) => {
const orderId = cols[idxOrderId] || ''
const newRewardStr = cols[idxNew] || ''
const remark = idxRemark >= 0 ? (cols[idxRemark] || '') : ''
const order = orders.value.find(o => o.id === orderId)
const currentReward = order?.actualReward ?? order?.estimatedReward
let status = 'OK'
let error = ''
if (!orderId) {
status = 'ERROR'
error = '订单ID为空'
} else if (!order) {
status = 'ERROR'
error = '订单不存在'
}
const newReward = Number(newRewardStr)
if (Number.isNaN(newReward)) {
status = 'ERROR'
error = error ? `${error}; 新酬金非数字` : '新酬金非数字'
} else if (newReward < 0 || newReward > 1000) {
status = 'ERROR'
error = error ? `${error}; 新酬金需在0~1000之间` : '新酬金需在0~1000之间'
}
const row = {
orderId,
currentReward: currentReward ?? '',
newReward: Number.isNaN(newReward) ? '' : newReward,
remark,
status,
error
}
preview.push(row)
if (status === 'OK') {
valid.push(row)
} else {
invalid.push(row)
}
})
return { preview, valid, invalid }
}
// 上传变更
const handleBatchFileChange = (file: any) => {
if (!file?.raw) return
const raw: File = file.raw
batchFileName.value = raw.name
const reader = new FileReader()
reader.onload = () => {
const text = String(reader.result || '')
const rows = parseCsv(text)
const { preview, valid, invalid } = buildPreviewRows(rows)
batchPreviewRows.value = preview
batchValidRows.value = valid
batchInvalidRows.value = invalid
batchParseDone.value = true
if (valid.length === 0) {
ElMessage.warning('未检测到有效记录,请检查文件内容')
}
}
reader.onerror = () => {
ElMessage.error('读取文件失败,请重试')
}
reader.readAsText(raw, 'utf-8')
}
const closeBatchDialog = () => {
isBatchModifyDialogOpen.value = false
}
const openVerifyDialog = () => {
isVerifyDialogOpen.value = true
}
// 执行变更(演示校验码:123456)
const applyBatchModify = () => {
if (verifyCode.value !== '123456') {
ElMessage.error('验证码错误')
return
}
const updates = batchValidRows.value
if (updates.length === 0) {
ElMessage.error('没有可执行的修改')
return
}
// 应用到本地订单列表
const newOrders = orders.value.map(o => {
const u = updates.find(x => x.orderId === o.id)
if (!u) return o
return {
...o,
actualReward: Number(u.newReward)
}
})
orders.value = newOrders
emit('orders-update', newOrders)
ElMessage.success(`成功修改 ${updates.length} 个订单`)
isVerifyDialogOpen.value = false
isBatchModifyDialogOpen.value = false
resetBatchState()
}
// 初始化数据
onMounted(() => {
if (props.orders.length === 0) {
const newOrders = generateMockOrders()
orders.value = newOrders
emit('orders-update', newOrders)
} else {
orders.value = props.orders
}
})
// 监听props变化
watch(() => props.orders, (newOrders) => {
if (newOrders.length > 0) {
orders.value = newOrders
}
}, { deep: true })
</script>
<style scoped>
/* 搜索框样式 */
:deep(.search-input .el-input__wrapper) {
background-color: #f3f3f5;
border: none;
padding-left: 36px;
box-shadow: none;
height: 40px;
}
:deep(.search-input .el-input__inner) {
background-color: transparent;
}
/* 表格容器样式 */
.table-container {
padding: 24px;
}
/* 轮廓与圆角已在全局 main.css 统一控制,这里不再重复设置,避免视觉上变粗 */
/* 表头样式 - 灰色背景 */
:deep(.order-table .el-table__header-wrapper) {
background-color: #f3f4f6;
}
:deep(.order-table .el-table__header) {
background-color: #f3f4f6;
}
/* 表头底色已在全局设置,这里仅保留需要的细化样式 */
/* 表格行样式 */
:deep(.order-table .el-table__row:hover) {
background-color: #f8fafc;
}
/* 单元格边线已在全局统一(仅底部分隔,无纵向分割线) */
/* 表格头部圆角 */
:deep(.order-table .el-table__header tr th:first-child) {
border-top-left-radius: 8px;
}
:deep(.order-table .el-table__header tr th:last-child) {
border-top-right-radius: 8px;
}
/* 斑马纹样式优化 */
:deep(.order-table .el-table__row.el-table__row--striped) {
background-color: #fafafa;
}
:deep(.order-table .el-table__row.el-table__row--striped:hover) {
background-color: #f8fafc;
}
/* 表格选择框(含表头全选)可见性与主题色 */
:deep(.order-table .el-table__header .el-checkbox .el-checkbox__inner),
:deep(.order-table .el-table__body .el-checkbox .el-checkbox__inner) {
background-color: #ffffff !important;
border-color: #9ca3af !important; /* 中性灰,保证在灰色表头上可见 */
}
:deep(.order-table .el-checkbox .el-checkbox__inner:hover) {
border-color: #3b82f6 !important;
}
:deep(.order-table .el-checkbox.is-checked .el-checkbox__inner),
:deep(.order-table .el-checkbox__input.is-indeterminate .el-checkbox__inner) {
background-color: #3b82f6 !important; /* 主题蓝 */
border-color: #3b82f6 !important;
}
:deep(.order-table .el-checkbox.is-checked .el-checkbox__inner::after) {
border-color: #ffffff !important; /* 对勾为白色,更清晰 */
}
/* 固定列样式 */
:deep(.order-table .el-table__fixed-right) {
box-shadow: -8px 0 16px 0px rgba(0, 0, 0, 0.12);
}
:deep(.order-table .el-table__fixed-right .el-table__cell) {
background-color: white;
}
:deep(.order-table .el-table__fixed-right .el-table th.el-table__cell) {
background-color: #f3f4f6 !important;
}
/* 操作列按钮样式 - 确保纯文字链接效果,无背景色 */
:deep(.el-button.el-button--primary.is-link) {
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
padding: 4px 8px !important;
box-shadow: none !important;
}
:deep(.el-button.el-button--primary.is-link:hover) {
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
box-shadow: none !important;
}
:deep(.el-button.el-button--primary.is-link:focus) {
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
box-shadow: none !important;
}
:deep(.el-button.el-button--primary.is-link:active) {
background-color: transparent !important;
background: transparent !important;
background-image: none !important;
border: none !important;
box-shadow: none !important;
}
/* 固定列样式优化 */
:deep(.el-table__fixed-right) {
box-shadow: -8px 0 16px 0px rgba(0, 0, 0, 0.12), -4px 0 6px -2px rgba(0, 0, 0, 0.08) !important;
}
:deep(.el-table__fixed-right .el-table__cell) {
background-color: white !important;
}
:deep(.el-table__fixed-right .el-table th.el-table__cell) {
background-color: #f3f4f6 !important;
}
/* 日期范围选择器高度调整 */
:deep(.el-date-editor--daterange.el-input__wrapper) {
height: 40px !important;
min-height: 40px !important;
}
:deep(.el-date-editor--daterange .el-range-input) {
height: 38px !important;
line-height: 38px !important;
}
</style>
\ No newline at end of file \ No newline at end of file
<template>
<div class="space-y-1">
<div
v-for="org in organizations"
:key="org.id"
class="space-y-1"
>
<div
:class="[
'flex items-center gap-3 py-2 pl-2 pr-3 rounded-lg transition-all border cursor-pointer',
!isSelectable(org)
? 'opacity-40 cursor-not-allowed border-transparent bg-transparent'
: selectedId === org.id
? 'border-blue-500 bg-white shadow-[0_0_0_2px_rgba(59,130,246,0.12)]'
: 'border-transparent hover:border-neutral-200 hover:bg-neutral-50'
]"
@click="handleSelect(org)"
>
<!-- 展开/收起按钮 -->
<button
v-if="hasChildren(org)"
@click.stop="toggleExpand(org.id)"
class="p-1 hover:bg-neutral-200 rounded-md transition-colors"
>
<ChevronDown
v-if="expandedIds.has(org.id)"
class="h-4 w-4 text-neutral-600"
/>
<ChevronRight
v-else
class="h-4 w-4 text-neutral-600"
/>
</button>
<div v-else class="w-6" />
<div
:class="[
'w-4 h-4 rounded-sm border flex items-center justify-center transition-colors',
selectedId === org.id
? 'border-blue-500 bg-blue-500 ring-2 ring-blue-100'
: isSelectable(org)
? 'border-neutral-300 bg-white'
: 'border-dashed border-neutral-300 bg-neutral-100'
]"
>
<span
v-if="selectedId === org.id"
class="inline-block w-2 h-2 rounded-sm bg-white"
/>
</div>
<!-- 组织图标 -->
<Building2
:class="[
'h-4 w-4 transition-colors',
!isSelectable(org)
? 'text-neutral-300'
: selectedId === org.id
? 'text-blue-600'
: 'text-neutral-500'
]"
/>
<!-- 组织信息 -->
<div class="flex-1">
<div class="flex items-center gap-2">
<span
:class="[
!isSelectable(org)
? 'text-neutral-400'
: selectedId === org.id
? 'text-blue-600 font-medium'
: 'text-neutral-900'
]"
>
{{ org.name }}
</span>
<el-tag
size="small"
effect="plain"
class="text-neutral-600 border-neutral-200 !bg-[#f5f7fb]"
>
{{ getOrgTypeLabel(org.type) }}
</el-tag>
<span
v-if="!isSelectable(org)"
class="text-xs text-neutral-400"
>
(不可选)
</span>
</div>
</div>
</div>
<!-- 子组织 -->
<div
v-if="hasChildren(org) && expandedIds.has(org.id)"
class="ml-8 pl-2 border-l border-dashed border-neutral-200 space-y-1"
>
<OrganizationTree
:organizations="org.children!"
:selected-id="selectedId"
:role-id="roleId"
:expanded-ids="expandedIds"
:account-type="accountType"
@select="$emit('select', $event)"
@toggle-expand="handleToggleExpand"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Building2, ChevronDown, ChevronRight } from 'lucide-vue-next'
interface Organization {
id: string
name: string
type: '地市' | '区县' | '客户经理团队'
parentId?: string
children?: Organization[]
}
interface Role {
id: string
name: string
level: '地市级' | '区县级' | '一线人员'
}
interface Props {
organizations: Organization[]
selectedId?: string
roleId?: string
roles?: Role[]
expandedIds?: Set<string>
accountType?: '地市级' | '区县级' | '一线人员'
}
const props = withDefaults(defineProps<Props>(), {
selectedId: '',
roleId: '',
roles: () => [],
expandedIds: () => new Set(),
accountType: ''
})
const emit = defineEmits<{
select: [orgId: string]
'toggle-expand': [orgId: string]
}>()
// 如果没有传入 expandedIds,使用本地状态
const localExpandedIds = ref(new Set<string>())
const expandedIds = computed(() =>
props.expandedIds && props.expandedIds.size > 0
? props.expandedIds
: localExpandedIds.value
)
// 工具函数
const hasChildren = (org: Organization): boolean => {
return !!(org.children && org.children.length > 0)
}
const isSelectable = (org: Organization): boolean => {
console.log('检查组织可选性:', org.name, org.type, '账号类型:', props.accountType)
// 只根据账号类型判断,不根据角色层级判断
if (props.accountType) {
switch (props.accountType) {
case '地市级':
// 地市级只能选择地市级组织
console.log('地市级账号,检查组织类型是否为地市:', org.type === '地市')
return org.type === '地市'
case '区县级':
// 区县级只能选择区县级组织
console.log('区县级账号,检查组织类型是否为区县:', org.type === '区县')
return org.type === '区县'
case '一线人员':
// 一线人员只能选择区县下的客户经理团队
console.log('一线人员账号,检查组织类型是否为客户经理团队:', org.type === '客户经理团队')
if (org.type !== '客户经理团队') {
return false
}
// 检查该客户经理团队是否属于某个区县
const result = isUnderCounty(org)
console.log('客户经理团队是否属于区县下:', result)
return result
default:
return true
}
}
// 如果没有账号类型,可以选择所有组织
return true
}
// 检查组织是否属于区县下
const isUnderCounty = (org: Organization): boolean => {
console.log('OrganizationTree.vue:202 检查组织是否属于区县下:', org.name, org.type, org.id)
// 从根节点开始构建完整的组织映射,确保包含所有组织
const buildOrgMap = (organizations: Organization[], orgMap: Map<string, Organization> = new Map()) => {
organizations.forEach(o => {
orgMap.set(o.id, o)
console.log('OrganizationTree.vue:210 添加组织到映射:', o.id, o.name, o.type)
if (o.children) {
buildOrgMap(o.children, orgMap)
}
})
return orgMap
}
// 使用完整的组织数据构建映射
const orgMap = buildOrgMap(props.organizations)
console.log('OrganizationTree.vue:218 组织映射大小:', orgMap.size)
// 从当前组织开始向上查找
let currentOrg = org
while (currentOrg) {
console.log('OrganizationTree.vue:224 查找父组织ID:', currentOrg.parentId)
const parentOrg = currentOrg.parentId ? orgMap.get(currentOrg.parentId) : undefined
console.log('OrganizationTree.vue:226 找到父组织:', parentOrg?.name, parentOrg?.type)
if (parentOrg) {
if (parentOrg.type === '区县') {
console.log('OrganizationTree.vue:230 找到区县父组织:', parentOrg.name)
return true
}
currentOrg = parentOrg
} else {
console.log('OrganizationTree.vue:236 未找到父组织,退出循环')
break
}
}
console.log('OrganizationTree.vue:241 未找到区县父组织')
return false
}
const getOrgTypeLabel = (type: Organization['type']) => {
switch (type) {
case '地市':
return '地市'
case '区县':
return '区县'
case '客户经理团队':
return '客户经理团队'
default:
return type
}
}
const getRoleLevel = (roleId: string): string => {
if (!props.roles || props.roles.length === 0) return '区县级'
const role = props.roles.find(r => r.id === roleId)
return role ? role.level : '区县级'
}
// 事件处理
const handleSelect = (org: Organization) => {
if (isSelectable(org)) {
emit('select', org.id)
}
}
const toggleExpand = (orgId: string) => {
if (props.expandedIds && props.expandedIds.size > 0) {
// 使用父组件的展开状态
emit('toggle-expand', orgId)
} else {
// 使用本地展开状态
if (localExpandedIds.value.has(orgId)) {
localExpandedIds.value.delete(orgId)
} else {
localExpandedIds.value.add(orgId)
}
}
}
const handleToggleExpand = (orgId: string) => {
emit('toggle-expand', orgId)
}
// 初始化展开顶级组织
if (props.expandedIds && props.expandedIds.size === 0) {
props.organizations.forEach(org => {
if (!org.parentId) {
localExpandedIds.value.add(org.id)
}
})
}
</script>
<style scoped>
.space-y-1 > * + * {
margin-top: 0.25rem;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 0.5rem;
}
.p-1 {
padding: 0.25rem;
}
.p-2 {
padding: 0.5rem;
}
.rounded {
border-radius: 0.25rem;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-not-allowed {
cursor: not-allowed;
}
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.opacity-40 {
opacity: 0.4;
}
.bg-blue-50 {
background-color: #eff6ff;
}
.border {
border-width: 1px;
}
.border-blue-500 {
border-color: #3b82f6;
}
.hover\:bg-neutral-50:hover {
background-color: #f9fafb;
}
.hover\:bg-neutral-200:hover {
background-color: #e5e7eb;
}
.h-4 {
height: 1rem;
}
.w-4 {
width: 1rem;
}
.w-6 {
width: 1.5rem;
}
.text-neutral-300 {
color: #d1d5db;
}
.text-neutral-400 {
color: #9ca3af;
}
.text-neutral-500 {
color: #6b7280;
}
.text-neutral-600 {
color: #4b5563;
}
.text-neutral-900 {
color: #111827;
}
.text-blue-600 {
color: #2563eb;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.flex-1 {
flex: 1 1 0%;
}
.ml-4 {
margin-left: 1rem;
}
.border-l-2 {
border-left-width: 2px;
}
.border-neutral-200 {
border-color: #e5e7eb;
}
</style>
<template>
<div class="space-y-1">
<div
:class="`permission-tree-node flex items-center gap-2 p-2 rounded ${level > 0 ? 'ml-6' : ''}`"
>
<!-- 展开/收起按钮 -->
<button
v-if="hasChildren"
@click="$emit('toggleExpand', permission.id)"
class="p-1 hover:bg-neutral-200 rounded flex items-center justify-center w-6 h-6"
>
<el-icon class="text-neutral-600">
<ArrowDown v-if="isExpanded" />
<ArrowRight v-else />
</el-icon>
</button>
<div v-else class="w-6" />
<!-- 复选框 -->
<el-checkbox
:model-value="isSelected"
@change="$emit('togglePermission', permission.id)"
:indeterminate="isIndeterminate"
/>
<!-- 权限信息 -->
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-neutral-900">{{ permission.name }}</span>
<el-tag
type="info"
effect="plain"
size="small"
class="text-xs"
>
{{ permission.code }}
</el-tag>
</div>
<p v-if="permission.description" class="text-xs text-neutral-500 mt-1">
{{ permission.description }}
</p>
</div>
</div>
<!-- 子权限 -->
<div v-if="hasChildren && isExpanded" class="ml-4 border-l-2 border-neutral-200">
<PermissionTreeNode
v-for="child in permission.children"
:key="child.id"
:permission="child"
:selected-permissions="selectedPermissions"
:expanded-permissions="expandedPermissions"
:level="level + 1"
@toggle-permission="$emit('togglePermission', $event)"
@toggle-expand="$emit('toggleExpand', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowDown, ArrowRight } from '@element-plus/icons-vue'
// 类型定义
interface Permission {
id: string
name: string
code: string
description?: string
parentId?: string
children?: Permission[]
}
// Props
interface Props {
permission: Permission
selectedPermissions: string[]
expandedPermissions: Set<string>
level: number
}
const props = defineProps<Props>()
// Emits
defineEmits<{
togglePermission: [permissionId: string]
toggleExpand: [permissionId: string]
}>()
// 计算属性
const hasChildren = computed(() =>
props.permission.children && props.permission.children.length > 0
)
const isExpanded = computed(() =>
props.expandedPermissions.has(props.permission.id)
)
const isSelected = computed(() =>
props.selectedPermissions.includes(props.permission.id)
)
const isIndeterminate = computed(() => {
if (isSelected.value || !hasChildren.value) return false
return props.permission.children!.some(c =>
props.selectedPermissions.includes(c.id)
)
})
</script>
<style scoped>
/* 组件特定样式 */
</style>
<template>
<div class="space-y-6">
<!-- 页面标题和操作区 -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-neutral-900 text-[20px] font-bold">角色管理</h3>
<p class="text-sm text-neutral-500 mt-1">创建和管理系统角色,为角色分配相应权限</p>
</div>
<el-button
type="primary"
@click="handleOpenAddDialog"
class="bg-brand-primary hover:bg-brand-primary/90 border-brand-primary"
>
<el-icon class="mr-1"><Plus /></el-icon>
创建角色
</el-button>
</div>
<!-- 角色列表 -->
<div class="p-5 bg-white rounded-lg">
<div class="rounded-lg overflow-hidden">
<el-table
:data="filteredRoles"
style="width: 100%"
:header-cell-style="{ backgroundColor: '#f3f4f6', color: '#374151', fontWeight: '500', borderBottom: '1px solid #e5e7eb' }"
:row-style="{ borderBottom: '1px solid rgb(243 244 246)' }"
>
<el-table-column prop="name" label="角色名称" min-width="120">
<template #default="{ row }">
<span class="text-neutral-900">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="permissionIds" label="权限数量" min-width="100">
<template #default="{ row }">
<el-tag
type="info"
effect="plain"
size="small"
class="bg-neutral-50"
>
{{ row.permissionIds.length }} 个权限
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="80">
<template #default="{ row }">
<el-tag
:type="row.status === '启用' ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="150">
<template #default="{ row }">
<span class="text-neutral-600">{{ row.createTime || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleOpenEditDialog(row)"
class="text-brand-primary hover:text-brand-primary"
>
编辑
</el-button>
</template>
</el-table-column>
<template #empty>
<div class="text-center py-12 text-neutral-500">
暂无角色数据
</div>
</template>
</el-table>
</div>
</div>
<!-- 创建/编辑角色对话框 -->
<el-dialog
v-model="isDialogOpen"
:title="editingRole ? '编辑角色' : '创建角色'"
width="800px"
:close-on-click-modal="false"
class="role-dialog"
>
<div class="space-y-6 max-h-[60vh] overflow-y-auto pr-2">
<!-- 基本信息 -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-900 mb-2">
角色名称 <span class="text-red-500">*</span>
</label>
<el-input
v-model="roleName"
placeholder="如:区县全权管理员"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-900 mb-2">
角色描述 <span class="text-neutral-500">(选填)</span>
</label>
<el-input
v-model="roleDescription"
type="textarea"
:rows="3"
placeholder="简要描述该角色的职责和权限范围..."
class="w-full"
/>
</div>
<div class="flex items-center justify-between p-4 bg-neutral-50 rounded border border-neutral-200">
<div>
<label class="block text-sm font-medium text-neutral-900">状态</label>
<p class="text-sm text-neutral-500 mt-1">停用后,该角色无法被分配给账号</p>
</div>
<el-switch
v-model="roleStatusEnabled"
/>
</div>
</div>
<!-- 权限选择 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-900">
权限选择 <span class="text-red-500">*</span>
</label>
<el-tag
type="primary"
effect="plain"
class="bg-brand-primary/10 text-brand-primary border-brand-primary"
>
已选择 {{ selectedPermissions.length }} 个权限
</el-tag>
</div>
<div class="border border-neutral-300 rounded p-4 bg-neutral-50 max-h-96 overflow-y-auto">
<div class="space-y-1">
<PermissionTreeNode
v-for="permission in topLevelPermissions"
:key="permission.id"
:permission="permission"
:selected-permissions="selectedPermissions"
:expanded-permissions="expandedPermissions"
@toggle-permission="handleTogglePermission"
@toggle-expand="toggleExpand"
:level="0"
/>
</div>
</div>
<p class="text-xs text-neutral-500">
提示:选择父权限会自动选择所有子权限,取消父权限会同时取消所有子权限
</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="isDialogOpen = false">
取消
</el-button>
<el-button
type="primary"
@click="handleSave"
class="bg-brand-primary border-brand-primary"
>
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import PermissionTreeNode from './PermissionTreeNode.vue'
// 类型定义
export interface Permission {
id: string
name: string
code: string
description?: string
parentId?: string
children?: Permission[]
}
export interface Role {
id: string
name: string
description?: string
level: RoleLevel
permissionIds: string[]
status: RoleStatus
createTime?: string
}
export type RoleLevel = '地市级' | '区县级' | '一线人员'
export type RoleStatus = '启用' | '禁用'
// Props
interface Props {
roles: Role[]
permissions: Permission[]
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
addRole: [role: Omit<Role, 'id' | 'createTime'>]
updateRole: [id: string, updates: Partial<Role>]
}>()
// 响应式数据
const isDialogOpen = ref(false)
const editingRole = ref<Role | null>(null)
// 表单状态
const roleName = ref('')
const roleDescription = ref('')
const selectedPermissions = ref<string[]>([])
const roleStatusEnabled = ref(true)
// 权限树展开状态
const expandedPermissions = ref<Set<string>>(new Set())
// 计算属性
const roleStatus = computed((): RoleStatus => roleStatusEnabled.value ? '启用' : '禁用')
const topLevelPermissions = computed(() =>
props.permissions.filter(p => !p.parentId)
)
const filteredRoles = computed(() =>
props.roles.filter(role => role.name !== '地市主管理员')
)
// 打开新增对话框
const handleOpenAddDialog = () => {
editingRole.value = null
roleName.value = ''
roleDescription.value = ''
selectedPermissions.value = []
roleStatusEnabled.value = true
expandedPermissions.value = new Set(props.permissions.filter(p => !p.parentId).map(p => p.id))
isDialogOpen.value = true
}
// 打开编辑对话框
const handleOpenEditDialog = (role: Role) => {
editingRole.value = role
roleName.value = role.name
roleDescription.value = role.description || ''
selectedPermissions.value = [...role.permissionIds]
roleStatusEnabled.value = role.status === '启用'
expandedPermissions.value = new Set(props.permissions.filter(p => !p.parentId).map(p => p.id))
isDialogOpen.value = true
}
// 保存角色
const handleSave = () => {
if (!roleName.value.trim()) {
ElMessage.error('请输入角色名称')
return
}
if (selectedPermissions.value.length === 0) {
ElMessage.error('请至少选择一个权限')
return
}
const roleData = {
name: roleName.value.trim(),
description: roleDescription.value.trim(),
permissionIds: selectedPermissions.value,
status: roleStatus.value
}
if (editingRole.value) {
emit('updateRole', editingRole.value.id, roleData)
ElMessage.success('角色更新成功')
} else {
emit('addRole', roleData)
ElMessage.success('角色创建成功')
}
isDialogOpen.value = false
}
// 切换权限选择
const handleTogglePermission = (permissionId: string) => {
const permission = findPermissionById(permissionId)
if (!permission) return
let newSelected = [...selectedPermissions.value]
if (newSelected.includes(permissionId)) {
// 取消选择:同时取消所有子权限
newSelected = newSelected.filter(id => {
const p = findPermissionById(id)
return !isDescendantOf(p, permissionId)
})
newSelected = newSelected.filter(id => id !== permissionId)
} else {
// 选择:自动选择所有父权限
newSelected.push(permissionId)
let current = permission
while (current.parentId) {
if (!newSelected.includes(current.parentId)) {
newSelected.push(current.parentId)
}
current = findPermissionById(current.parentId)!
}
}
selectedPermissions.value = newSelected
}
// 查找权限
const findPermissionById = (id: string): Permission | undefined => {
const find = (perms: Permission[]): Permission | undefined => {
for (const p of perms) {
if (p.id === id) return p
if (p.children) {
const found = find(p.children)
if (found) return found
}
}
return undefined
}
return find(props.permissions)
}
// 判断是否是某个权限的后代
const isDescendantOf = (permission: Permission | undefined, ancestorId: string): boolean => {
if (!permission) return false
if (permission.parentId === ancestorId) return true
if (!permission.parentId) return false
return isDescendantOf(findPermissionById(permission.parentId), ancestorId)
}
// 切换权限树节点展开
const toggleExpand = (permissionId: string) => {
const newExpanded = new Set(expandedPermissions.value)
if (newExpanded.has(permissionId)) {
newExpanded.delete(permissionId)
} else {
newExpanded.add(permissionId)
}
expandedPermissions.value = newExpanded
}
</script>
<style scoped>
:deep(.role-dialog .el-dialog__header) {
padding: 24px 24px 0 24px !important;
border-bottom: none !important;
margin-bottom: 0;
}
:deep(.role-dialog .el-dialog__body) {
padding: 24px !important;
}
:deep(.role-dialog .el-dialog__footer) {
padding: 0 24px 24px 24px !important;
border-top: 1px solid #f0f0f0;
margin-top: 0;
}
:deep(.el-table .el-table__cell) {
border-bottom: 1px solid rgb(243 244 246);
}
:deep(.el-table--border::after) {
display: none;
}
:deep(.el-table--border .el-table__cell) {
border-right: none;
}
:deep(.el-table th.el-table__cell) {
background-color: rgb(239 246 255 / 0.5);
border-bottom: 1px solid rgb(229 231 235);
color: #374151;
font-weight: 500;
}
</style>
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vue’s
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
<template>
<div class="space-y-6">
<!-- 页面标题和操作区 -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-neutral-900 text-[20px] font-bold">账号管理</h3>
<p class="text-sm text-neutral-500 mt-1">创建和管理系统用户,分配组织和角色</p>
</div>
<el-button
type="primary"
class="h-10 px-4"
@click="handleOpenAddDialog"
>
<Plus class="h-4 w-4 mr-1" />
新增用户
</el-button>
</div>
<!-- 搜索和筛选 -->
<el-card>
<div class="grid grid-cols-5 gap-4 px-6 py-6">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500 z-10" />
<el-input
v-model="generalSearch"
placeholder="搜索用户姓名/手机号/工号"
class="h-10 search-input text-ellipsis-input"
clearable
/>
</div>
<el-select
v-model="roleFilter"
placeholder="角色"
class="h-10 w-full"
clearable
>
<el-option label="全部角色" value="all" />
<el-option
v-for="role in availableRoles"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
<el-select
v-model="accountTypeFilter"
placeholder="账号类型"
class="h-10 w-full"
clearable
>
<el-option label="全部类型" value="all" />
<el-option label="地市级" value="地市级" />
<el-option label="区县级" value="区县级" />
<el-option label="一线人员" value="一线人员" />
</el-select>
<el-select
v-model="statusFilter"
placeholder="状态"
class="h-10 w-full"
clearable
>
<el-option label="全部状态" value="all" />
<el-option label="正常" value="正常" />
<el-option label="禁用" value="禁用" />
</el-select>
<el-select
v-model="organizationFilter"
placeholder="所属组织"
class="h-10 w-full"
clearable
>
<el-option label="全部组织" value="all" />
<el-option
v-for="org in organizationOptions"
:key="org.id"
:label="org.label"
:value="org.id"
/>
</el-select>
</div>
</el-card>
<!-- 用户列表 -->
<el-card class="p-5">
<div class="rounded-lg overflow-hidden">
<el-table
:data="filteredUsers"
style="width: 100%"
:header-cell-style="{ backgroundColor: '#f3f4f6', color: '#374151', fontWeight: '500', borderBottom: '1px solid #e5e7eb' }"
:row-style="getRowStyle"
>
<el-table-column prop="realName" label="用户姓名" min-width="100">
<template #default="{ row }">
<div class="flex items-center gap-2">
<span :class="{ 'font-semibold': isCurrentUser(row) }">{{ row.realName }}</span>
<el-tag
v-if="isCurrentUser(row)"
type="primary"
size="small"
class="text-xs"
>
当前登录
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" min-width="120">
<template #default="{ row }">
{{ row.phone || '-' }}
</template>
</el-table-column>
<el-table-column prop="username" label="工号" min-width="120">
<template #default="{ row }">
<span :class="{ 'font-semibold': isCurrentUser(row) }">{{ row.username }}</span>
</template>
</el-table-column>
<el-table-column prop="accountType" label="账号类型" min-width="100">
<template #default="{ row }">
<el-tag
:type="getAccountTypeTagType(row.accountType)"
effect="plain"
size="small"
>
{{ row.accountType || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="roleId" label="角色" min-width="120">
<template #default="{ row }">
{{ getRoleById(row.roleId)?.name || '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="80">
<template #default="{ row }">
<el-tag
:type="row.status === '正常' ? 'success' : 'info'"
size="small"
>
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="organizationId" label="所属组织" min-width="150">
<template #default="{ row }">
<div v-if="getOrganizationById(row.organizationId)" class="flex items-center gap-2">
<el-tag size="small" type="info">{{ getOrganizationById(row.organizationId)?.type }}</el-tag>
<span>{{ getOrganizationById(row.organizationId)?.name }}</span>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="120">
<template #default="{ row }">
{{ row.createTime || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleOpenEditDialog(row)"
>
编辑
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- 新增/编辑用户对话框 -->
<el-dialog
v-model="isDialogOpen"
:title="editingUser ? '编辑用户' : '新增用户'"
width="800px"
top="5vh"
:close-on-click-modal="false"
class="user-management-dialog"
>
<div class="space-y-6">
<!-- 基本信息 -->
<div class="space-y-4">
<h3 class="text-neutral-900 pb-2 border-b border-neutral-200">基本信息</h3>
<div class="grid grid-cols-2 gap-x-4">
<el-form-item label="工号" required>
<el-input
v-model="formData.username"
placeholder="员工工号"
:disabled="!!editingUser"
/>
</el-form-item>
<el-form-item label="用户姓名" required>
<el-input
v-model="formData.realName"
placeholder="真实姓名"
/>
</el-form-item>
<el-form-item label="手机号" required>
<el-input
v-model="formData.phone"
placeholder="11位手机号"
/>
</el-form-item>
<el-form-item label="账号类型" required>
<el-select
v-model="formData.accountType"
placeholder="选择账号类型"
class="w-full"
@change="handleAccountTypeChange"
>
<el-option label="地市级" value="地市级" />
<el-option label="区县级" value="区县级" />
<el-option label="一线人员" value="一线人员" />
</el-select>
</el-form-item>
<el-form-item label="角色" required class="col-span-2">
<el-select
v-model="formData.roleId"
placeholder="选择角色"
class="w-full"
@change="handleRoleChange"
>
<el-option
v-for="role in availableRoles"
:key="role.id"
:label="`${role.name}${role.description ? ` - ${role.description}` : ''}`"
:value="role.id"
/>
</el-select>
<div v-if="roleHint" class="text-xs text-neutral-500 mt-1">
{{ roleHint }}
</div>
</el-form-item>
</div>
<div class="flex items-center justify-between p-4 bg-neutral-50 rounded border border-neutral-200">
<div>
<div class="text-neutral-900 font-medium">用户状态</div>
<p class="text-sm text-neutral-500 mt-1">禁用后,该用户无法登录系统</p>
</div>
<el-switch
v-model="formData.isActive"
/>
</div>
</div>
<!-- 所属组织(当前登录用户不可修改) -->
<div v-if="!editingUser || !isCurrentUser(editingUser)" class="space-y-4">
<div class="flex items-center justify-between pb-2 border-b border-neutral-200">
<h3 class="text-neutral-900">所属组织 <span class="text-red-500">*</span></h3>
<el-tag
v-if="formData.organizationId"
type="primary"
size="small"
>
已选择
</el-tag>
</div>
<div class="border border-neutral-300 rounded p-4 bg-neutral-50 max-h-[250px] overflow-y-auto">
<OrganizationTree
:organizations="topLevelOrganizations"
:selected-id="formData.organizationId"
:role-id="formData.roleId"
:roles="roles"
:expanded-ids="organizationExpandedIds"
:account-type="formData.accountType"
@select="handleOrganizationSelect"
@toggle-expand="handleToggleExpand"
/>
</div>
<p class="text-xs text-neutral-500">
提示:点击组织名称选择,点击箭头展开/收起子组织。灰色不可选的组织表示不符合当前角色的层级要求。
</p>
</div>
<!-- 当前登录用户的组织信息(只读显示) -->
<div v-else-if="editingUser && isCurrentUser(editingUser)" class="space-y-4">
<h3 class="text-neutral-900 pb-2 border-b border-neutral-200">所属组织</h3>
<div class="p-4 bg-neutral-50 rounded border border-neutral-200">
<div class="flex items-center gap-3">
<Building2 class="h-5 w-5 text-neutral-500" />
<div>
<div class="flex items-center gap-2">
<el-tag size="small" type="info">
{{ getOrganizationById(editingUser.organizationId)?.type }}
</el-tag>
<span class="text-neutral-900">
{{ getOrganizationById(editingUser.organizationId)?.name }}
</span>
</div>
<p class="text-xs text-neutral-500 mt-1">
当前登录账号不可自行修改所属组织,如需修改请联系上级管理员
</p>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="isDialogOpen = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Building2, Search } from 'lucide-vue-next'
import OrganizationTree from './OrganizationTree.vue'
// 类型定义
interface User {
id: string
username: string
realName: string
organizationId: string
roleId: string
accountType?: '地市级' | '区县级' | '一线人员'
phone?: string
email?: string
status: '正常' | '禁用'
createTime?: string
creatorId?: string
}
interface Role {
id: string
name: string
description?: string
level: '地市级' | '区县级' | '一线人员'
status: '启用' | '禁用'
permissions: string[]
}
interface Organization {
id: string
name: string
type: '地市' | '区县' | '客户经理团队'
parentId?: string
children?: Organization[]
}
// Props
interface UserManagementProps {
users: User[]
roles: Role[]
organizations: Organization[]
currentUserId: string
}
const props = defineProps<UserManagementProps>()
const emit = defineEmits<{
addUser: [user: Omit<User, 'id' | 'createTime'>]
updateUser: [id: string, updates: Partial<User>]
}>()
// 响应式数据
const isDialogOpen = ref(false)
const editingUser = ref<User | null>(null)
const generalSearch = ref('')
const roleFilter = ref<string>('all')
const accountTypeFilter = ref<'all' | '地市级' | '区县级' | '一线人员'>('all')
const statusFilter = ref<'all' | '正常' | '禁用'>('all')
const organizationFilter = ref<string>('all')
const organizationExpandedIds = ref(new Set<string>())
// 表单数据
const formData = ref({
username: '',
realName: '',
phone: '',
accountType: '',
organizationId: '',
roleId: '',
isActive: true
})
// 计算属性
const availableRoles = computed(() =>
props.roles.filter(r => r.status === '启用' && r.name !== '地市主管理员')
)
const topLevelOrganizations = computed(() =>
props.organizations.filter(o => !o.parentId)
)
const totalUsers = computed(() => props.users.length - 1) // 排除当前登录用户
const hasFilters = computed(() =>
generalSearch.value !== '' ||
roleFilter.value !== 'all' ||
accountTypeFilter.value !== 'all' ||
statusFilter.value !== 'all' ||
organizationFilter.value !== 'all'
)
const filteredUsers = computed(() => {
return props.users.filter(user => {
// 过滤掉当前登录用户
if (user.id === props.currentUserId) return false
// 通用搜索:支持搜索用户姓名、手机号、工号
const searchTerm = generalSearch.value.toLowerCase()
let matchGeneral = true
if (searchTerm !== '') {
matchGeneral = user.realName.toLowerCase().includes(searchTerm) ||
(user.phone && user.phone.toLowerCase().includes(searchTerm)) ||
user.username.toLowerCase().includes(searchTerm)
}
const matchRole = roleFilter.value === 'all' || user.roleId === roleFilter.value
const matchAccountType = accountTypeFilter.value === 'all' || user.accountType === accountTypeFilter.value
const matchStatus = statusFilter.value === 'all' || user.status === statusFilter.value
const matchOrganization = organizationFilter.value === 'all' || user.organizationId === organizationFilter.value
return matchGeneral && matchRole && matchAccountType && matchStatus && matchOrganization
})
})
const roleHint = computed(() => {
if (!formData.value.roleId) return ''
const role = getRoleById(formData.value.roleId)
if (!role) return ''
return `${role.name}`
})
// 工具函数
const getOrganizationById = (id: string): Organization | undefined => {
const find = (orgs: Organization[]): Organization | undefined => {
for (const org of orgs) {
if (org.id === id) return org
if (org.children) {
const found = find(org.children)
if (found) return found
}
}
return undefined
}
return find(props.organizations)
}
const getRoleById = (id: string): Role | undefined => {
return props.roles.find(r => r.id === id)
}
const getAccountTypeTagType = (accountType: string) => {
switch (accountType) {
case '地市级': return 'warning'
case '区县级': return 'primary'
case '一线人员': return 'success'
default: return 'info'
}
}
const isCurrentUser = (user: User): boolean => {
return user.id === props.currentUserId
}
const getRowStyle = ({ row }: { row: User }) => {
if (isCurrentUser(row)) {
return { backgroundColor: '#f0f9ff', borderLeft: '4px solid #1677ff' }
}
return {}
}
// 事件处理
const handleOpenAddDialog = () => {
editingUser.value = null
formData.value = {
username: '',
realName: '',
phone: '',
accountType: '',
organizationId: '',
roleId: '',
isActive: true
}
isDialogOpen.value = true
}
const handleOpenEditDialog = (user: User) => {
editingUser.value = user
formData.value = {
username: user.username,
realName: user.realName,
phone: user.phone || '',
accountType: user.accountType || '',
organizationId: user.organizationId,
roleId: user.roleId,
isActive: user.status === '正常'
}
isDialogOpen.value = true
}
const handleAccountTypeChange = () => {
// 当账号类型变更时,清空组织选择
formData.value.organizationId = ''
// 根据账号类型设置组织选择的层级限制
const accountType = formData.value.accountType
if (accountType === '地市级') {
ElMessage.info('账号类型为地市级,所属组织只能选择地市级')
} else if (accountType === '区县级') {
ElMessage.info('账号类型为区县级,所属组织只能选择区县级')
} else if (accountType === '一线人员') {
ElMessage.info('账号类型为一线人员,所属组织只能选择客户经理团队')
}
}
const handleRoleChange = () => {
// 角色变更时不进行层级检查
}
const handleOrganizationSelect = (orgId: string) => {
formData.value.organizationId = orgId
}
const handleToggleExpand = (orgId: string) => {
if (organizationExpandedIds.value.has(orgId)) {
organizationExpandedIds.value.delete(orgId)
} else {
organizationExpandedIds.value.add(orgId)
}
}
const isOrganizationSelectable = (orgType: string, roleId: string): boolean => {
// 只根据账号类型进行组织层级限制,不根据角色层级限制
const accountType = formData.value.accountType
switch (accountType) {
case '地市级':
// 地市级只能选择地市级组织
return orgType === '地市'
case '区县级':
// 区县级只能选择区县级组织
return orgType === '区县'
case '一线人员':
// 一线人员只能选择客户经理团队
return orgType === '客户经理团队'
default:
// 如果没有选择账号类型,可以选择所有组织
return true
}
}
const handleSave = () => {
// 表单验证
if (!formData.value.username.trim()) {
ElMessage.error('请输入工号')
return
}
if (!formData.value.realName.trim()) {
ElMessage.error('请输入用户姓名')
return
}
if (!formData.value.phone.trim()) {
ElMessage.error('请输入手机号')
return
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.phone.trim())) {
ElMessage.error('请输入正确的11位手机号')
return
}
// 当前登录用户不需要验证组织(不可修改)
if (!editingUser.value || !isCurrentUser(editingUser.value)) {
if (!formData.value.organizationId) {
ElMessage.error('请选择所属组织')
return
}
}
if (!formData.value.roleId) {
ElMessage.error('请选择角色')
return
}
if (!formData.value.accountType) {
ElMessage.error('请选择账号类型')
return
}
// 检查用户名是否重复
const existingUser = props.users.find(u =>
u.username === formData.value.username.trim() && u.id !== editingUser.value?.id
)
if (existingUser) {
ElMessage.error('该工号已存在')
return
}
const userData: any = {
username: formData.value.username.trim(),
realName: formData.value.realName.trim(),
roleId: formData.value.roleId,
accountType: formData.value.accountType,
status: formData.value.isActive ? '正常' : '禁用',
phone: formData.value.phone.trim() || undefined,
creatorId: props.currentUserId
}
// 当前登录用户不可修改自己的所属组织
if (!editingUser.value || !isCurrentUser(editingUser.value)) {
userData.organizationId = formData.value.organizationId
}
if (editingUser.value) {
emit('updateUser', editingUser.value.id, userData)
ElMessage.success('用户更新成功')
} else {
emit('addUser', userData)
ElMessage.success('用户创建成功')
}
isDialogOpen.value = false
}
// 组织下拉选项(扁平化带缩进)
const organizationOptions = computed(() => {
const result: { id: string; label: string }[] = []
const traverse = (orgs: Organization[], depth = 0) => {
const prefix = depth > 0 ? '— '.repeat(depth) : ''
orgs.forEach(org => {
result.push({ id: org.id, label: `${prefix}${org.name}` })
if (org.children && org.children.length) traverse(org.children, depth + 1)
})
}
traverse(props.organizations || [])
return result
})
</script>
<style scoped>
/* 让表单项标签和输入框呈上下结构 */
:deep(.el-form-item) {
display: flex;
flex-direction: column;
align-items: stretch;
}
:deep(.el-form-item__label) {
margin-bottom: 0;
text-align: left !important;
padding: 0 !important;
}
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
.space-y-6 > * + * {
margin-top: 1.5rem;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
.grid {
display: grid;
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.gap-4 {
gap: 1rem;
}
.h-10 {
height: 2.5rem;
}
.w-full {
width: 100%;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
.text-neutral-500 {
color: #6b7280;
}
.text-neutral-700 {
color: #374151;
}
.text-neutral-900 {
color: #111827;
}
.text-blue-600 {
color: #2563eb;
}
.text-red-500 {
color: #ef4444;
}
.border-neutral-200 {
border-color: #e5e7eb;
}
.border-neutral-300 {
border-color: #d1d5db;
}
.bg-neutral-50 {
background-color: #f9fafb;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-lg {
border-radius: 0.5rem;
}
.border {
border-width: 1px;
}
.border-b {
border-bottom-width: 1px;
}
.p-4 {
padding: 1rem;
}
.p-5 {
padding: 1.25rem;
}
.p-6 {
padding: 1.5rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mr-1 {
margin-right: 0.25rem;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
.max-h-\[400px\] {
max-height: 400px;
}
/* 用户管理对话框样式 */
:deep(.user-management-dialog) {
max-height: 90vh;
margin-top: 5vh !important;
margin-bottom: 5vh !important;
display: flex;
flex-direction: column;
}
:deep(.user-management-dialog .el-dialog__body) {
flex: 1;
overflow-y: auto;
max-height: calc(90vh - 120px); /* 减去头部和底部的空间 */
padding: 24px !important;
}
:deep(.user-management-dialog .el-dialog__header) {
flex-shrink: 0;
padding: 24px 24px 0 24px !important;
}
:deep(.user-management-dialog .el-dialog__footer) {
flex-shrink: 0;
padding: 0 24px 24px 24px !important;
}
/* 搜索输入框样式 - 与订单管理保持一致 */
:deep(.search-input .el-input__wrapper) {
padding-left: 2.5rem !important;
}
:deep(.search-input .el-input__inner) {
padding-left: 0 !important;
}
</style>
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
import './assets/main.css'
import './assets/form-styles.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
// Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
},
{
path: '/role-test',
name: 'role-test',
component: () => import('../views/RoleTestView.vue'),
},
],
})
export default router
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>
<template>
<div class="min-h-screen bg-gray-50 p-6">
<div class="max-w-7xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 mb-6">角色管理测试页面</h1>
<RoleManagement
:roles="roles"
:permissions="permissions"
@add-role="handleAddRole"
@update-role="handleUpdateRole"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import RoleManagement from '@/components/RoleManagement.vue'
import { ElMessage } from 'element-plus'
// 类型定义
interface Role {
id: string
name: string
description?: string
level: '地市级' | '区县级' | '一线人员'
permissionIds: string[]
status: '启用' | '禁用'
createTime?: string
}
interface Permission {
id: string
name: string
code: string
description?: string
parentId?: string
children?: Permission[]
}
// 测试数据
const roles = ref<Role[]>([
{
id: 'role-001',
name: '区县全权管理员',
description: '拥有区县级别的所有管理权限,可以管理订单、用户和业务规则',
level: '区县级',
permissionIds: [
'order',
'order:view',
'order:complete',
'order:reward',
'order:approve',
'business',
'business:view',
'business:create',
'business:edit',
'system',
'system:role',
'system:role:view',
'system:role:create',
'system:role:edit',
'system:account',
'system:account:view',
'system:account:create',
'system:account:edit'
],
status: '启用',
createTime: '2025-10-20 09:00:00'
},
{
id: 'role-002',
name: '订单管理员',
description: '负责订单的日常管理和处理',
level: '一线人员',
permissionIds: ['order', 'order:view', 'order:complete', 'order:reward', 'order:approve'],
status: '启用',
createTime: '2025-10-20 09:30:00'
},
{
id: 'role-003',
name: '业务规则管理员',
description: '负责业务规则的配置和维护',
level: '区县级',
permissionIds: ['business', 'business:view', 'business:create', 'business:edit'],
status: '启用',
createTime: '2025-10-20 10:00:00'
}
])
const permissions = ref<Permission[]>([
{
id: 'order',
name: '订单管理',
code: 'order',
description: '订单相关的所有权限',
children: [
{
id: 'order:view',
name: '查看订单',
code: 'order:view',
description: '查看订单列表和详情',
parentId: 'order'
},
{
id: 'order:complete',
name: '填写办结信息',
code: 'order:complete',
description: '填写CRM订单编号和办理备注',
parentId: 'order'
},
{
id: 'order:reward',
name: '填写酬金金额',
code: 'order:reward',
description: '填写和修改实际发放酬金',
parentId: 'order'
},
{
id: 'order:approve',
name: '审核',
code: 'order:approve',
description: '审核通过或驳回订单',
parentId: 'order'
}
]
},
{
id: 'business',
name: '业务规则管理',
code: 'business',
description: '业务规则相关权限',
children: [
{
id: 'business:view',
name: '查看业务规则',
code: 'business:view',
description: '查看业务规则列表',
parentId: 'business'
},
{
id: 'business:create',
name: '创建业务规则',
code: 'business:create',
description: '创建新的业务规则',
parentId: 'business'
},
{
id: 'business:edit',
name: '编辑业务规则',
code: 'business:edit',
description: '修改和停用业务规则',
parentId: 'business'
}
]
},
{
id: 'system',
name: '系统管理',
code: 'system',
description: '系统管理相关权限',
children: [
{
id: 'system:role',
name: '角色管理',
code: 'system:role',
description: '角色管理相关权限',
parentId: 'system',
children: [
{
id: 'system:role:view',
name: '查看角色',
code: 'system:role:view',
description: '查看角色列表',
parentId: 'system:role'
},
{
id: 'system:role:create',
name: '创建角色',
code: 'system:role:create',
description: '创建新角色',
parentId: 'system:role'
},
{
id: 'system:role:edit',
name: '编辑角色',
code: 'system:role:edit',
description: '编辑角色信息和权限',
parentId: 'system:role'
}
]
},
{
id: 'system:account',
name: '账号管理',
code: 'system:account',
description: '账号管理相关权限',
parentId: 'system',
children: [
{
id: 'system:account:view',
name: '查看账号',
code: 'system:account:view',
description: '查看账号列表',
parentId: 'system:account'
},
{
id: 'system:account:create',
name: '创建账号',
code: 'system:account:create',
description: '创建新账号',
parentId: 'system:account'
},
{
id: 'system:account:edit',
name: '编辑账号',
code: 'system:account:edit',
description: '编辑账号信息',
parentId: 'system:account'
}
]
}
]
}
])
// 事件处理
const handleAddRole = (roleData: Omit<Role, 'id' | 'createTime'>) => {
const newRole: Role = {
id: `role-${Date.now()}`,
...roleData,
createTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
roles.value.push(newRole)
ElMessage.success('角色创建成功')
console.log('新增角色:', newRole)
}
const handleUpdateRole = (roleId: string, updates: Partial<Role>) => {
const roleIndex = roles.value.findIndex(role => role.id === roleId)
if (roleIndex !== -1) {
roles.value[roleIndex] = { ...roles.value[roleIndex], ...updates }
ElMessage.success('角色更新成功')
console.log('更新角色:', roleId, updates)
}
}
</script>
<style scoped>
/* 测试页面样式 */
</style>
{
"port": 4001,
"appPort": 5178,
"autoPlugins": true,
"plugins": []
}
\ No newline at end of file \ No newline at end of file
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// 品牌主色
'brand-primary': '#3b82f6',
'brand-primary-hover': '#2563eb',
'brand-primary-light': '#dbeafe',
// 功能色
'success': '#10b981',
'warning': '#f59e0b',
'error': '#d4183d',
'info': '#3b82f6',
// 中性色
'neutral-50': '#f9fafb',
'neutral-100': '#f3f4f6',
'neutral-200': '#e5e7eb',
'neutral-300': '#e5e7eb',
'neutral-400': '#d1d5db',
'neutral-500': '#9ca3af',
'neutral-600': '#6b7280',
'neutral-700': '#4b5563',
'neutral-800': '#374151',
'neutral-900': '#1f2937',
},
borderRadius: {
lg: '10px',
md: '8px',
sm: '6px',
xl: '14px',
},
fontFamily: {
sans: ['DIN', '"Source Han Sans CN"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'system-ui', 'sans-serif'],
mono: ['DIN', 'monospace'],
},
},
},
plugins: [],
}
\ No newline at end of file \ No newline at end of file
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
// vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!