Compare commits
9 Commits
main
..
b11fe9916f
| Author | SHA1 | Date | |
|---|---|---|---|
| b11fe9916f | |||
| d2a1df0e2b | |||
| 4fb8c5aec9 | |||
| 28d05b8f08 | |||
| 6d11711244 | |||
| 826eefa1ac | |||
| 6328b8dbb1 | |||
| b1dd60ab6e | |||
| 82a19101a8 |
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: 前端 Bug 报告
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
about: 报告前端页面或组件的问题
|
||||
---
|
||||
|
||||
## 问题描述
|
||||
|
||||
清晰描述问题现象
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1. 进入 xxx 页面
|
||||
2. 点击 xxx 按钮
|
||||
3. 出现 xxx 错误
|
||||
|
||||
## 期望结果
|
||||
|
||||
描述正确的行为
|
||||
|
||||
## 实际结果
|
||||
|
||||
描述实际出现的问题
|
||||
|
||||
## 环境信息
|
||||
|
||||
- **浏览器**:Chrome / Firefox / Safari / Edge
|
||||
- **分辨率**:1920x1080 / 移动端
|
||||
- **项目**:admin-ui / cashier-mobile / customer-mobile
|
||||
|
||||
## 截图
|
||||
|
||||
如有截图请粘贴
|
||||
|
||||
## 备注
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: 功能需求
|
||||
title: "[FEATURE] "
|
||||
labels: ["feature"]
|
||||
about: 提出新的功能需求
|
||||
---
|
||||
|
||||
## 需求描述
|
||||
|
||||
描述需要什么功能
|
||||
|
||||
## 使用场景
|
||||
|
||||
描述这个功能的使用场景
|
||||
|
||||
## 期望实现
|
||||
|
||||
描述期望的实现方式
|
||||
|
||||
## 优先级
|
||||
|
||||
- [ ] P0 - 阻塞
|
||||
- [ ] P1 - 高
|
||||
- [ ] P2 - 中
|
||||
- [ ] P3 - 低
|
||||
|
||||
## 关联需求
|
||||
|
||||
- 需要后端接口支持:[创建 API-REQ Issue]
|
||||
- 相关设计稿:
|
||||
|
||||
## 备注
|
||||
@@ -0,0 +1,145 @@
|
||||
name: 前端构建与检查
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feature/**', 'feat/**']
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
lint-and-type-check:
|
||||
name: 代码检查
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 运行 ESLint
|
||||
run: pnpm lint || true
|
||||
|
||||
- name: 运行类型检查
|
||||
run: pnpm type-check || true
|
||||
|
||||
build-admin-ui:
|
||||
name: 构建 Admin UI
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-type-check
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 构建 Admin UI
|
||||
run: pnpm build:admin
|
||||
|
||||
- name: 上传构建产物
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-ui-dist
|
||||
path: admin-ui/dist
|
||||
retention-days: 7
|
||||
|
||||
build-cashier-mobile:
|
||||
name: 构建收银移动端
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-type-check
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 构建收银移动端
|
||||
run: pnpm build:cashier
|
||||
|
||||
- name: 上传构建产物
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cashier-mobile-dist
|
||||
path: cashier-mobile/dist
|
||||
retention-days: 7
|
||||
|
||||
build-customer-mobile:
|
||||
name: 构建顾客移动端
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-type-check
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 构建顾客移动端
|
||||
run: pnpm build:customer
|
||||
|
||||
- name: 上传构建产物
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: customer-mobile-dist
|
||||
path: customer-mobile/dist
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.local
|
||||
.env.production
|
||||
.cache/
|
||||
*.log
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
</profile>
|
||||
</component>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SshConsoleOptionsProvider">
|
||||
<option name="myEncoding" value="UTF-8" />
|
||||
<option name="myConnectionType" value="NONE" />
|
||||
<option name="myConnectionId" value="" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "rui-frontend",
|
||||
"description": "睿核科技前端开发会话 - 负责 rui-frontend 目录下的前端代码",
|
||||
"scope": ["admin-ui/**", "cashier-mobile/**", "customer-mobile/**"],
|
||||
"readonly": [],
|
||||
"prompt": "你是 rui 前端开发助手。\n\n## 工作范围\n你只能修改 rui-frontend/ 目录下的前端代码。\n\n## 技术栈\n- Vue 3 + TypeScript\n- Element Plus(admin-ui)\n- Vite\n- pnpm workspace\n\n## 编码规范\n1. 使用 `<script setup lang=\"ts\">`\n2. Props 和 Emit 必须定义类型\n3. API 服务层封装在 service/ 目录\n4. 状态管理使用 Pinia(Setup Store 风格)\n5. 样式使用 UnoCSS + SCSS\n\n## 协作规则\n1. 需要后端接口时,提醒用户创建 Gitee Issue\n2. 不修改后端代码(backend/、app/)\n3. 遵循 AGENTS.md 规范\n\n## 常用命令\n- pnpm dev:admin - 启动管理后台\n- pnpm build:admin - 构建管理后台\n- pnpm install - 安装依赖",
|
||||
"git": {
|
||||
"defaultBranch": "main",
|
||||
"commitMessageFormat": "type(scope): 中文描述"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
# rui-frontend AGENTS.md
|
||||
|
||||
> **睿核科技前端项目** — Vue 3 + TypeScript 前端工程
|
||||
> **版本**: v1.0
|
||||
> **更新日期**: 2026-06-04
|
||||
|
||||
---
|
||||
|
||||
## 一、项目概览
|
||||
|
||||
### 1.1 项目信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **项目名称** | rui-frontend |
|
||||
| **项目类型** | Vue 3 前端工程(多项目管理)|
|
||||
| **仓库地址** | https://gitee.com/rui/rui-frontend |
|
||||
| **包管理器** | pnpm |
|
||||
| **构建工具** | Vite |
|
||||
|
||||
### 1.2 仓库结构
|
||||
|
||||
```
|
||||
rui-frontend/
|
||||
├── admin-ui/ # 管理后台系统
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 接口
|
||||
│ │ ├── assets/ # 静态资源
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── composables/ # 组合式函数
|
||||
│ │ ├── layouts/ # 布局组件
|
||||
│ │ ├── locales/ # 国际化
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ ├── service/ # 服务层(API 调用)
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── styles/ # 全局样式
|
||||
│ │ ├── types/ # 类型定义
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ └── views/ # 页面视图
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
│
|
||||
├── cashier-mobile/ # 收银系统移动端(待开发)
|
||||
├── customer-mobile/ # 收银系统顾客端(待开发)
|
||||
├── package.json # 根 package.json
|
||||
├── pnpm-workspace.yaml # pnpm 工作区配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
### 1.3 技术栈
|
||||
|
||||
| 组件 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Vue | 3.5+ | 前端框架 |
|
||||
| TypeScript | 5.4+ | 类型系统 |
|
||||
| Vite | 5.x | 构建工具 |
|
||||
| Element Plus | 2.9+ | UI 组件库(admin-ui)|
|
||||
| Pinia | 2.2+ | 状态管理 |
|
||||
| Vue Router | 4.4+ | 路由管理 |
|
||||
| Axios | 1.7+ | HTTP 客户端 |
|
||||
| pnpm | 8.x | 包管理器 |
|
||||
|
||||
---
|
||||
|
||||
## 二、编码规范
|
||||
|
||||
### 2.1 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| **组件文件** | PascalCase | `UserForm.vue`, `OrderList.vue` |
|
||||
| **组合式函数** | camelCase, use 前缀 | `useUser.ts`, `usePermission.ts` |
|
||||
| **工具函数** | camelCase | `formatDate.ts`, `deepClone.ts` |
|
||||
| **常量** | UPPER_SNAKE_CASE | `API_BASE_URL`, `DEFAULT_PAGE_SIZE` |
|
||||
| **类型定义** | PascalCase, 后缀 Type | `UserType`, `OrderFormType` |
|
||||
| **接口定义** | PascalCase, 前缀 I | `IUser`, `IOrder` |
|
||||
| **枚举** | PascalCase, 后缀 Enum | `StatusEnum`, `GenderEnum` |
|
||||
|
||||
### 2.2 Vue 组件规范
|
||||
|
||||
#### 文件结构
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 1. 类型导入
|
||||
import type { UserType } from '@/types'
|
||||
|
||||
// 2. Vue 核心导入
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
// 3. 第三方库
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 4. 本地模块
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { formatDate } from '@/utils/date'
|
||||
|
||||
// 5. 组合式函数
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 6. 类型定义
|
||||
interface Props {
|
||||
userId: number
|
||||
}
|
||||
|
||||
// 7. Props & Emits
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
submit: [data: UserType]
|
||||
}>()
|
||||
|
||||
// 8. 响应式数据
|
||||
const userList = ref<UserType[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 9. 计算属性
|
||||
const userCount = computed(() => userList.value.length)
|
||||
|
||||
// 10. 方法
|
||||
async function fetchUserList() {
|
||||
loading.value = true
|
||||
try {
|
||||
// API 调用
|
||||
} catch (error) {
|
||||
ElMessage.error('获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 生命周期
|
||||
onMounted(() => {
|
||||
fetchUserList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<!-- 模板内容 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-list {
|
||||
// 样式
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
#### 组件编写原则
|
||||
|
||||
- **使用 `<script setup>`**:简洁、类型友好
|
||||
- **使用 TypeScript**:所有组件必须 `lang="ts"`
|
||||
- ** Props 必须定义类型**:使用 `defineProps<Props>()`
|
||||
- **Emit 必须定义类型**:使用 `defineEmits<{}>()`
|
||||
- **避免直接使用 any**:必须定义类型
|
||||
|
||||
### 2.3 API 服务层规范
|
||||
|
||||
```typescript
|
||||
// service/userService.ts
|
||||
import request from '@/utils/request'
|
||||
import type { UserType, PageResult } from '@/types'
|
||||
|
||||
export const UserService = {
|
||||
// 查询列表
|
||||
getList(params: PageParams) {
|
||||
return request.get<PageResult<UserType>>('/user/admin/list', { params })
|
||||
},
|
||||
|
||||
// 查询详情
|
||||
getById(id: number) {
|
||||
return request.get<UserType>(`/user/admin/${id}`)
|
||||
},
|
||||
|
||||
// 新增
|
||||
create(data: UserFormType) {
|
||||
return request.post('/user/admin', data)
|
||||
},
|
||||
|
||||
// 修改
|
||||
update(id: number, data: UserFormType) {
|
||||
return request.put(`/user/admin/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除
|
||||
delete(id: number) {
|
||||
return request.delete(`/user/admin/${id}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**原则**:
|
||||
- 使用对象封装相关 API
|
||||
- 返回类型必须明确
|
||||
- 统一错误处理在 request 拦截器中
|
||||
|
||||
### 2.4 请求工具封装
|
||||
|
||||
```typescript
|
||||
// utils/request.ts
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const { code, msg, data } = response.data
|
||||
if (code === 200) {
|
||||
return data
|
||||
}
|
||||
ElMessage.error(msg || '请求失败')
|
||||
return Promise.reject(new Error(msg))
|
||||
},
|
||||
(error) => {
|
||||
ElMessage.error(error.message || '网络错误')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
```
|
||||
|
||||
### 2.5 状态管理(Pinia)
|
||||
|
||||
```typescript
|
||||
// stores/user.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserType } from '@/types'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
const userInfo = ref<UserType | null>(null)
|
||||
const token = ref('')
|
||||
|
||||
// Getters
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
// Actions
|
||||
function setUserInfo(user: UserType) {
|
||||
userInfo.value = user
|
||||
}
|
||||
|
||||
function logout() {
|
||||
userInfo.value = null
|
||||
token.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
token,
|
||||
isLoggedIn,
|
||||
setUserInfo,
|
||||
logout
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**原则**:
|
||||
- 使用 Setup Store 风格(函数式)
|
||||
- State 使用 `ref()`
|
||||
- Getters 使用 `computed()`
|
||||
- Actions 使用普通函数
|
||||
|
||||
### 2.6 样式规范
|
||||
|
||||
- 使用 **UnoCSS** 原子化 CSS(已配置)
|
||||
- 复杂样式使用 **SCSS**
|
||||
- 组件样式使用 `<style scoped>`
|
||||
- 全局样式放在 `styles/` 目录
|
||||
|
||||
```vue
|
||||
<!-- 推荐:原子化 CSS -->
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 bg-white rounded">
|
||||
<span class="text-lg font-bold">标题</span>
|
||||
<el-button type="primary">按钮</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 复杂组件使用 SCSS -->
|
||||
<style scoped lang="scss">
|
||||
.user-form {
|
||||
&__header {
|
||||
@apply flex items-center justify-between;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、目录规范
|
||||
|
||||
### 3.1 新增页面步骤
|
||||
|
||||
1. 在 `views/` 下创建目录和文件
|
||||
2. 在 `router/` 下添加路由配置
|
||||
3. 在 `service/` 下添加 API(如需要)
|
||||
4. 在 `types/` 下添加类型(如需要)
|
||||
|
||||
### 3.2 组件分类
|
||||
|
||||
| 位置 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `components/common/` | 全局通用组件 | `RuiTable.vue`, `IconPicker.vue` |
|
||||
| `views/{module}/` | 页面级组件 | `views/user/Index.vue` |
|
||||
| `views/{module}/components/` | 页面私有组件 | `views/user/components/UserForm.vue` |
|
||||
|
||||
---
|
||||
|
||||
## 四、开发流程
|
||||
|
||||
### 4.1 新增功能流程
|
||||
|
||||
```
|
||||
1. 确认后端接口已就绪(Swagger 文档)
|
||||
2. 创建类型定义(types/)
|
||||
3. 创建 API 服务(service/)
|
||||
4. 创建页面组件(views/)
|
||||
5. 配置路由(router/)
|
||||
6. 联调测试
|
||||
7. 提交代码
|
||||
```
|
||||
|
||||
### 4.2 与后端协作
|
||||
|
||||
1. **需要新接口**:
|
||||
- 在 spring-ai 仓库创建 `[API-REQ]` Issue
|
||||
- 等待后端实现并回复 Swagger 地址
|
||||
- 根据 Swagger 开发前端
|
||||
|
||||
2. **接口变更**:
|
||||
- 关注后端 Issue 更新
|
||||
- 及时更新前端 service/
|
||||
|
||||
---
|
||||
|
||||
## 五、Git 提交规范
|
||||
|
||||
### 5.1 提交格式
|
||||
|
||||
```
|
||||
type(scope): 中文描述
|
||||
|
||||
示例:
|
||||
feat(user): 添加用户列表页面
|
||||
fix(order): 修复订单金额显示错误
|
||||
docs(readme): 更新项目说明
|
||||
style(css): 优化表格样式
|
||||
```
|
||||
|
||||
### 5.2 Type 类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | 修复 Bug |
|
||||
| `docs` | 文档更新 |
|
||||
| `style` | 代码格式(不影响功能)|
|
||||
| `refactor` | 重构 |
|
||||
| `perf` | 性能优化 |
|
||||
| `test` | 测试相关 |
|
||||
| `chore` | 构建/工具链 |
|
||||
|
||||
### 5.3 Scope 范围
|
||||
|
||||
| Scope | 说明 |
|
||||
|-------|------|
|
||||
| `admin-ui` | 管理后台 |
|
||||
| `cashier` | 收银系统 |
|
||||
| `common` | 公共代码 |
|
||||
| `deps` | 依赖更新 |
|
||||
|
||||
---
|
||||
|
||||
## 六、环境配置
|
||||
|
||||
### 6.1 环境变量
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_APP_TITLE=睿核管理系统
|
||||
|
||||
# .env.production
|
||||
VITE_API_BASE_URL=https://api.vifo.cc
|
||||
VITE_APP_TITLE=睿核管理系统
|
||||
```
|
||||
|
||||
### 6.2 代理配置
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、性能优化
|
||||
|
||||
### 7.1 代码分割
|
||||
|
||||
```typescript
|
||||
// 路由懒加载
|
||||
const UserList = () => import('@/views/user/Index.vue')
|
||||
|
||||
// 组件懒加载
|
||||
const Dialog = defineAsyncComponent(() => import('./Dialog.vue'))
|
||||
```
|
||||
|
||||
### 7.2 资源优化
|
||||
|
||||
- 图片使用 WebP 格式
|
||||
- 大组件使用异步加载
|
||||
- 使用 CDN 加载第三方库(生产环境)
|
||||
|
||||
---
|
||||
|
||||
## 八、测试规范
|
||||
|
||||
### 8.1 单元测试
|
||||
|
||||
```bash
|
||||
# 待配置 Vitest
|
||||
```
|
||||
|
||||
### 8.2 E2E 测试
|
||||
|
||||
```bash
|
||||
# 待配置 Playwright
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、OpenCode 使用规范
|
||||
|
||||
### 9.1 启动方式
|
||||
|
||||
```bash
|
||||
cd /Users/zhangsheng/rhkj/rui-frontend
|
||||
opencode
|
||||
```
|
||||
|
||||
### 9.2 启动提示词
|
||||
|
||||
```
|
||||
你现在进入【前端开发模式】。
|
||||
|
||||
工作目录:/Users/zhangsheng/rhkj/rui-frontend
|
||||
技术栈:Vue 3、TypeScript、Element Plus、Vite、pnpm
|
||||
规则:
|
||||
1. 只能修改前端项目目录下的代码
|
||||
2. 需要后端接口时,提醒用户创建 Gitee Issue
|
||||
3. 遵循本文档的编码规范
|
||||
4. 使用 pnpm,不要混用 npm/yarn
|
||||
|
||||
当前任务:【描述具体任务】
|
||||
```
|
||||
|
||||
### 9.3 禁止事项
|
||||
|
||||
- ❌ 修改 backend/ 或 app/ 目录下的代码
|
||||
- ❌ 使用 npm/yarn(必须使用 pnpm)
|
||||
- ❌ 提交 node_modules/ 到 Git
|
||||
- ❌ 在代码中写死 API 地址(使用环境变量)
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- [项目 README](../README.md)
|
||||
- [后端项目规范](../../spring-ai/AGENTS.md)
|
||||
- [跨团队协作规范](../../spring-ai/docs/cross-team-workflow.md)
|
||||
- [OpenCode 操作指南](../../spring-ai/docs/opencode-workflow.md)
|
||||
|
||||
---
|
||||
|
||||
## 十一、附录
|
||||
|
||||
### 11.1 常用命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm dev:admin
|
||||
|
||||
# 构建
|
||||
pnpm build:admin
|
||||
|
||||
# 类型检查
|
||||
pnpm type-check
|
||||
|
||||
# 代码检查
|
||||
pnpm lint
|
||||
|
||||
# 安装子项目依赖
|
||||
cd admin-ui && pnpm install
|
||||
```
|
||||
|
||||
### 11.2 错误码对照
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权 |
|
||||
| 403 | 无权限 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
> **提示**:本文档是活文档,根据项目发展持续更新。如有建议请提交 PR。
|
||||
@@ -0,0 +1 @@
|
||||
# CI 测试 - 2026-06-04 06:20:17
|
||||
@@ -0,0 +1 @@
|
||||
钉钉消息测试 - 2026-06-04 06:37:23
|
||||
@@ -0,0 +1 @@
|
||||
# Gitea Test
|
||||
@@ -0,0 +1 @@
|
||||
JSON序列化测试 07:09:53
|
||||
@@ -1,121 +1,203 @@
|
||||
# Rui-Docs
|
||||
# rui-frontend
|
||||
|
||||
> 睿核科技项目文档中心 —— 前后端共享的独立文档仓库
|
||||
> 睿核科技前端项目集合
|
||||
|
||||
## 📁 目录结构
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
rui-docs/
|
||||
├── README.md # 本文档
|
||||
├── standards/ # 通用规范(前后端共享)
|
||||
│ ├── API设计规范.md
|
||||
│ ├── 前端开发规则.md
|
||||
│ ├── 数据库设计规范分析.md
|
||||
│ └── coding-standards.md
|
||||
├── backend/ # 后端相关文档
|
||||
│ ├── design/ # 设计文档
|
||||
│ ├── specs/ # 规格说明
|
||||
│ ├── guides/ # 操作指南
|
||||
│ │ ├── AI开发操作手册.md
|
||||
│ │ └── deployment-guide.md
|
||||
│ ├── 模块间通信规范.md
|
||||
│ ├── 跨团队协作工作流.md
|
||||
│ ├── 项目实施规范.md
|
||||
│ └── 业务应用模块创建规则.md
|
||||
└── frontend/ # 前端相关文档
|
||||
├── design/ # 设计文档
|
||||
├── specs/ # 规格说明
|
||||
├── plans/ # 实施计划
|
||||
├── admin-ui-icon-guide.md
|
||||
└── admin-ui-status.md
|
||||
rui-frontend/
|
||||
├── admin-ui/ # 管理后台(Vue 3 + Element Plus)
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── ...
|
||||
├── cashier-mobile/ # 收银系统移动端(占位)
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── ...
|
||||
├── customer-mobile/ # 收银系统顾客端(占位)
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── ...
|
||||
├── package.json # 根 package.json
|
||||
├── pnpm-workspace.yaml # pnpm 工作区配置
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 🔗 使用方式
|
||||
## 技术栈
|
||||
|
||||
### 作为 Git Submodule
|
||||
- **构建工具**:Vite
|
||||
- **框架**:Vue 3 + TypeScript
|
||||
- **UI 组件**:Element Plus(admin-ui)/ 待定(移动端)
|
||||
- **状态管理**:Pinia
|
||||
- **包管理器**:pnpm
|
||||
- **工作区**:pnpm workspace
|
||||
|
||||
在代码仓库中添加文档子模块:
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- pnpm >= 8.0.0
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 添加 submodule
|
||||
git submodule add ssh://git@git.vifo.cc:222/rui/rui-docs.git docs
|
||||
# 安装所有项目依赖
|
||||
pnpm install
|
||||
|
||||
# 更新到最新
|
||||
git submodule update --remote
|
||||
|
||||
# 初始化(新克隆时)
|
||||
git submodule update --init --recursive
|
||||
# 或只安装某个项目
|
||||
cd admin-ui
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 独立查看
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.vifo.cc:222/rui/rui-docs.git
|
||||
cd rui-docs
|
||||
# 启动管理后台
|
||||
pnpm dev:admin
|
||||
|
||||
# 启动收银移动端
|
||||
pnpm dev:cashier
|
||||
|
||||
# 启动顾客端
|
||||
pnpm dev:customer
|
||||
```
|
||||
|
||||
## 📋 文档索引
|
||||
### 构建
|
||||
|
||||
### 前端设计文档(frontend/design/)
|
||||
```bash
|
||||
# 构建所有项目
|
||||
pnpm build:all
|
||||
|
||||
| 文档 | 说明 | 更新日期 |
|
||||
|------|------|----------|
|
||||
| `rui-admin功能设计文档.md` | rui-admin 管理后台功能模块设计 | 2026-06-03 |
|
||||
| `cashier-design.md` | 收银系统(POS)整体架构设计 | 2026-06-03 |
|
||||
| `admin-ui-module-build-design.md` | Admin-UI 分模块打包功能设计 | 2026-06-04 |
|
||||
# 构建单个项目
|
||||
pnpm build:admin
|
||||
pnpm build:cashier
|
||||
pnpm build:customer
|
||||
```
|
||||
|
||||
### 前端实施计划(frontend/plans/)
|
||||
## 项目说明
|
||||
|
||||
| 文档 | 说明 | 更新日期 |
|
||||
|------|------|----------|
|
||||
| `cashier-admin-implementation.md` | 收银系统后台管理功能完善实施计划 | 2026-06-04 |
|
||||
### admin-ui
|
||||
|
||||
### 后端文档(backend/)
|
||||
管理后台系统,支持多系统切换(收银、超管、运营)。
|
||||
|
||||
| 文档 | 说明 | 更新日期 |
|
||||
|------|------|----------|
|
||||
| `模块间通信规范.md` | 微服务模块间通信规范(Feign/REST) | 2026-06-04 |
|
||||
| `跨团队协作工作流.md` | 多团队/多 AI 协作流程规范 | 2026-06-04 |
|
||||
| `项目实施规范.md` | 项目整体实施规范与架构说明 | 2026-06-04 |
|
||||
| `业务应用模块创建规则.md` | 业务模块(app/)创建标准 | 2026-06-04 |
|
||||
| `guides/AI开发操作手册.md` | AI 开发环境配置与操作指南 | 2026-06-04 |
|
||||
| `guides/deployment-guide.md` | 项目部署指南 | 2026-06-04 |
|
||||
- **技术栈**:Vue 3 + Element Plus + TypeScript
|
||||
- **端口**:3000
|
||||
- **构建命令**:`pnpm build:admin`
|
||||
|
||||
### 前端文档(frontend/)
|
||||
### cashier-mobile(待开发)
|
||||
|
||||
| 文档 | 说明 | 更新日期 |
|
||||
|------|------|----------|
|
||||
| `admin-ui-icon-guide.md` | Admin-UI 图标使用指南 | 2026-06-04 |
|
||||
| `admin-ui-status.md` | Admin-UI 状态管理文档 | 2026-06-04 |
|
||||
收银系统移动端,供店员使用。
|
||||
|
||||
### 通用规范(standards/)
|
||||
- **技术栈**:待定(UniApp / React Native / Flutter)
|
||||
- **功能**:开台、点餐、结账、退款
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| `API设计规范.md` | RESTful API 设计规范 |
|
||||
| `前端开发规则.md` | 前端编码规范、目录结构、命名约定 |
|
||||
| `数据库设计规范分析.md` | 数据库设计规范与约束 |
|
||||
| `coding-standards.md` | 通用编码规范(Java/TypeScript) |
|
||||
### customer-mobile(待开发)
|
||||
|
||||
## 🔄 文档维护规范
|
||||
收银系统顾客端,供顾客扫码点餐、支付。
|
||||
|
||||
1. **文档即代码**:所有文档使用 Markdown 格式,纳入版本控制
|
||||
2. **单一数据源**:本文档仓库是唯一的文档源头,代码仓库通过 submodule 引用
|
||||
3. **变更追溯**:文档修改需通过 PR/MR 流程,保留修改历史
|
||||
4. **定期同步**:代码仓库的 submodule 应定期更新到最新文档版本
|
||||
- **技术栈**:待定(微信小程序 / H5)
|
||||
- **功能**:扫码点餐、在线支付、订单查询
|
||||
|
||||
## 📝 新增文档流程
|
||||
## 开发规范
|
||||
|
||||
1. 在本文档仓库的对应目录下创建 `.md` 文件
|
||||
2. 提交并推送到远程
|
||||
3. 在代码仓库中执行 `git submodule update --remote` 获取更新
|
||||
### 代码规范
|
||||
|
||||
## 👥 参与人员
|
||||
- 使用 TypeScript
|
||||
- ESLint + Prettier 自动格式化
|
||||
- 组件名使用 PascalCase
|
||||
- 组合式函数使用 useXxx 命名
|
||||
|
||||
- **前端开发**:关注 `frontend/` 目录
|
||||
- **后端开发**:关注 `backend/` 目录
|
||||
- **架构师/全栈**:关注 `standards/` 目录
|
||||
### Git 提交规范
|
||||
|
||||
---
|
||||
```
|
||||
type(scope): 中文描述
|
||||
|
||||
> 📧 文档问题请联系项目维护者
|
||||
示例:
|
||||
feat(admin-ui): 添加用户管理页面
|
||||
fix(cashier): 修复结账金额计算错误
|
||||
docs: 更新 README
|
||||
```
|
||||
|
||||
type 类型:
|
||||
- `feat`:新功能
|
||||
- `fix`:修复
|
||||
- `docs`:文档
|
||||
- `style`:格式(不影响代码运行)
|
||||
- `refactor`:重构
|
||||
- `test`:测试
|
||||
- `chore`:构建/工具
|
||||
|
||||
### 目录规范
|
||||
|
||||
```
|
||||
{project}/src/
|
||||
├── api/ # API 接口定义
|
||||
├── assets/ # 静态资源
|
||||
├── components/ # 公共组件
|
||||
│ └── common/ # 通用组件
|
||||
├── composables/ # 组合式函数
|
||||
├── layouts/ # 布局组件
|
||||
├── router/ # 路由配置
|
||||
├── stores/ # Pinia 状态管理
|
||||
├── styles/ # 全局样式
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── utils/ # 工具函数
|
||||
└── views/ # 页面视图
|
||||
```
|
||||
|
||||
## 与后端协作
|
||||
|
||||
### API 接口
|
||||
|
||||
后端接口文档:`http://{backend-host}/doc.html`
|
||||
|
||||
前端通过 Axios 调用后端 API,基地址在 `.env` 文件中配置:
|
||||
|
||||
```
|
||||
# .env.development
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
### 跨团队协作
|
||||
|
||||
前端与后端通过 **Gitee Issue** 进行协作:
|
||||
|
||||
1. 前端需要新接口 → 在后端仓库创建 `[API-REQ]` Issue
|
||||
2. 后端实现后 → 在 Issue 回复 Swagger 地址
|
||||
3. 前端根据 Swagger 开发/联调
|
||||
|
||||
详见后端仓库文档:`docs/cross-team-workflow.md`
|
||||
|
||||
## CI/CD
|
||||
|
||||
### 构建流程
|
||||
|
||||
```
|
||||
code push → GitHub Actions → build → deploy
|
||||
```
|
||||
|
||||
### 部署环境
|
||||
|
||||
| 环境 | 分支 | 域名 |
|
||||
|------|------|------|
|
||||
| 开发 | develop | dev-frontend.vifo.cc |
|
||||
| 测试 | release/* | test-frontend.vifo.cc |
|
||||
| 生产 | main | admin.vifo.cc |
|
||||
|
||||
## 相关仓库
|
||||
|
||||
- **后端框架**:`spring-ai`(backend/ + app/)
|
||||
- **接口文档**:`http://{backend-host}/doc.html`
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建功能分支:`git checkout -b feat/xxx`
|
||||
3. 提交代码:`git commit -m "feat: xxx"`
|
||||
4. 推送分支:`git push origin feat/xxx`
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v2测试 07:13:54
|
||||
@@ -0,0 +1,6 @@
|
||||
# 管理后台默认租户编号
|
||||
# 该值会在每个 HTTP 请求的 X-Tenant-Id 请求头中透传至后端
|
||||
VITE_TENANT_ID=1
|
||||
# OAuth2 客户端密钥(client_id:client_secret 的 base64 编码)
|
||||
# 用于 /oauth2/token 接口的 Basic 认证
|
||||
VITE_OAUTH2_CLIENT_SECRET=cnVpLWNsaWVudDpydWktc2VjcmV0
|
||||
@@ -0,0 +1,18 @@
|
||||
# 管理后台 PC
|
||||
|
||||
Vue 3 + TypeScript + Vite + Element Plus + UnoCSS
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 `http://localhost:3000`
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"key": "cashier",
|
||||
"name": "收银系统",
|
||||
"description": "面向收银场景的管理后台",
|
||||
"modules": ["system", "user", "cms", "cashier"],
|
||||
"login": {
|
||||
"component": "Cashier",
|
||||
"showTenantInput": true,
|
||||
"title": "睿核收银",
|
||||
"subtitle": "门店管理系统"
|
||||
},
|
||||
"dashboard": {
|
||||
"component": "Cashier",
|
||||
"title": "收银数据概览"
|
||||
},
|
||||
"theme": {
|
||||
"primaryColor": "#1677ff",
|
||||
"title": "睿核收银"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"key": "default",
|
||||
"name": "默认系统",
|
||||
"description": "开发测试用,包含所有模块",
|
||||
"modules": ["system", "user", "order", "cms", "marketing", "demo", "cashier"],
|
||||
"login": {
|
||||
"component": "Default",
|
||||
"showTenantInput": true,
|
||||
"title": "睿核通用平台",
|
||||
"subtitle": "管理后台登录"
|
||||
},
|
||||
"dashboard": {
|
||||
"component": "Default",
|
||||
"title": "数据概览"
|
||||
},
|
||||
"theme": {
|
||||
"primaryColor": "#1677ff",
|
||||
"title": "睿核通用平台"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"key": "super",
|
||||
"name": "超级管理后台",
|
||||
"description": "超级租户专用,包含租户管理",
|
||||
"modules": ["system", "user"],
|
||||
"login": {
|
||||
"component": "Super",
|
||||
"showTenantInput": false,
|
||||
"title": "睿核平台管理",
|
||||
"subtitle": "超级管理员登录"
|
||||
},
|
||||
"dashboard": {
|
||||
"component": "Super",
|
||||
"title": "平台运营概览"
|
||||
},
|
||||
"theme": {
|
||||
"primaryColor": "#722ed1",
|
||||
"title": "睿核平台管理"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>睿核通用平台 — 管理后台</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
html, body, #app { height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "admin-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000",
|
||||
"dev:cashier": "vite --port 3000 -- --system=cashier",
|
||||
"dev:super": "vite --port 3000 -- --system=super",
|
||||
"build": "vite build",
|
||||
"build:cashier": "vite build -- --system=cashier",
|
||||
"build:super": "vite build -- --system=super",
|
||||
"build:admin": "vite build -- --system=admin",
|
||||
"build:all": "pnpm build:cashier && pnpm build:super && pnpm build:admin",
|
||||
"type-check": "vue-tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3",
|
||||
"@iconify-json/tabler": "^1.2.35",
|
||||
"@iconify/vue": "^5.0.1",
|
||||
"axios": "^1.7",
|
||||
"element-plus": "^2.9",
|
||||
"pinia": "^2.2",
|
||||
"vue": "^3.5",
|
||||
"vue-i18n": "^10.0",
|
||||
"vue-router": "^4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.11",
|
||||
"@unocss/preset-attributify": "^0.65",
|
||||
"@unocss/preset-icons": "^0.65",
|
||||
"@unocss/preset-uno": "^0.65",
|
||||
"@vitejs/plugin-vue": "^5.2",
|
||||
"@vue/tsconfig": "^0.7",
|
||||
"eslint": "^9.15",
|
||||
"sass": "^1.81",
|
||||
"typescript": "~5.6",
|
||||
"unocss": "^0.65",
|
||||
"unplugin-auto-import": "^0.18",
|
||||
"unplugin-vue-components": "^0.28",
|
||||
"vite": "^6.0",
|
||||
"vue-tsc": "^2.1"
|
||||
}
|
||||
}
|
||||
Generated
+5948
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Plugin, ViteDevServer } from 'vite'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { BuildConfig } from '../src/types/system-config'
|
||||
|
||||
/**
|
||||
* 模块路由映射表
|
||||
*/
|
||||
const moduleRouteMap: Record<string, string> = {
|
||||
system: "import { systemRoutes } from '@/router/modules/system'",
|
||||
user: "import { userRoutes } from '@/router/modules/user'",
|
||||
order: "import { orderRoutes } from '@/router/modules/order'",
|
||||
cms: "import { cmsRoutes } from '@/router/modules/cms'",
|
||||
marketing: "import { marketingRoutes } from '@/router/modules/marketing'",
|
||||
demo: "import { demoRoutes } from '@/router/modules/demo'",
|
||||
cashier: "import { cashierRoutes } from '@/router/modules/cashier'",
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成路由代码
|
||||
*/
|
||||
function generateRoutesCode(config: BuildConfig): string {
|
||||
const imports = config.modules
|
||||
.map((module) => moduleRouteMap[module])
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const routeArrays = config.modules
|
||||
.map((module) => {
|
||||
const varName = '...' + module + 'Routes'
|
||||
return varName
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
return `
|
||||
import { coreRoutes } from '@/router/modules/core'
|
||||
${imports}
|
||||
|
||||
const routes = [
|
||||
...coreRoutes.slice(0, 1), // login route
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/DefaultLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
...coreRoutes[1].children, // dashboard, profile, settings
|
||||
${routeArrays},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成系统配置代码
|
||||
*/
|
||||
function generateConfigCode(config: BuildConfig): string {
|
||||
return `export default ${JSON.stringify(config, null, 2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite 模块构建插件
|
||||
*/
|
||||
export default function moduleBuildPlugin(): Plugin {
|
||||
let config: BuildConfig | null = null
|
||||
let systemKey = 'default'
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-module-build',
|
||||
enforce: 'pre',
|
||||
|
||||
config(_config, { command }) {
|
||||
// 从命令行参数读取系统标识
|
||||
const args = process.argv
|
||||
const systemArg = args.find((arg) => arg.startsWith('--system='))
|
||||
if (systemArg) {
|
||||
systemKey = systemArg.replace('--system=', '')
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
const configPath = path.resolve(process.cwd(), 'build-config', `${systemKey}.json`)
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(`System config not found: ${configPath}`)
|
||||
}
|
||||
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
|
||||
// 修改构建输出目录
|
||||
return {
|
||||
build: {
|
||||
outDir: `dist/${config.key}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id === 'virtual:generated-routes' || id === 'virtual:system-config') {
|
||||
return id
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (!config) return null
|
||||
|
||||
if (id === 'virtual:generated-routes') {
|
||||
return generateRoutesCode(config)
|
||||
}
|
||||
|
||||
if (id === 'virtual:system-config') {
|
||||
return generateConfigCode(config)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
configureServer(server: ViteDevServer) {
|
||||
// 开发模式下,当配置文件变更时重启服务
|
||||
const configPath = path.resolve(server.config.root, 'build-config', `${systemKey}.json`)
|
||||
server.watcher.add(configPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
import systemConfig from 'virtual:system-config'
|
||||
|
||||
const app = useAppStore()
|
||||
const locale = computed(() => app.lang === 'zh-CN' ? zhCn : en)
|
||||
|
||||
onMounted(() => {
|
||||
// 设置页面标题
|
||||
document.title = systemConfig.theme.title
|
||||
|
||||
// 设置主题色
|
||||
const el = document.documentElement
|
||||
el.style.setProperty('--el-color-primary', systemConfig.theme.primaryColor)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="locale">
|
||||
<router-view />
|
||||
<!-- 全屏退出角标 -->
|
||||
<div v-if="app.pageFullscreen" class="fs-exit" @click="app.togglePageFullscreen()">
|
||||
<el-icon :size="18"><Close /></el-icon>
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root { --el-bg-color-page: #f5f6fa; }
|
||||
html.dark { --el-bg-color-page: #111; color-scheme: dark; }
|
||||
html.dark body { background: #111; color: #e5e7eb; }
|
||||
|
||||
.fullscreen-main {
|
||||
position: fixed !important; inset: 0 !important; z-index: 100 !important;
|
||||
background: #f5f6fa !important; padding: 20px !important; overflow: auto !important;
|
||||
}
|
||||
html.dark .fullscreen-main { background: #111 !important; }
|
||||
|
||||
.fs-exit {
|
||||
position: fixed; top: 16px; right: 16px; z-index: 200;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(0,0,0,0.4); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.fs-exit:hover { background: rgba(0,0,0,0.6); }
|
||||
|
||||
/* ======================== rui-dialog 全局样式规则 ======================== */
|
||||
/**
|
||||
* 所有使用 el-dialog 的组件必须添加 class="rui-dialog"
|
||||
* 规则说明:
|
||||
* 1. 内容区域最大高度 60vh,超出滚动
|
||||
* 2. 底部 footer 固定不参与滚动
|
||||
* 3. 用法:<el-dialog class="rui-dialog" ...>
|
||||
*/
|
||||
.rui-dialog .el-dialog__body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.rui-dialog .el-dialog__footer {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding: 12px 20px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
Vendored
+88
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
CashierTablePage: typeof import('./components/CashierTablePage.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
IconPicker: typeof import('./components/IconPicker.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
RuiIcon: typeof import('./components/RuiIcon.vue')['default']
|
||||
RuiTable: typeof import('./components/RuiTable.vue')['default']
|
||||
TagsBar: typeof import('./components/TagsBar.vue')['default']
|
||||
ThemeDrawer: typeof import('./components/ThemeDrawer.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
columns: any[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'add'): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const searchForm = ref({})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="text-xl font-bold mb-4">{{ title }}</h2>
|
||||
|
||||
<div class="toolbar mb-4">
|
||||
<el-button type="primary" @click="emit('add')">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="emit('refresh')">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="[]"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="col in columns"
|
||||
:key="col.prop"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:align="col.align || 'left'"
|
||||
/>
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small">编辑</el-button>
|
||||
<el-button link type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import RuiIcon from './RuiIcon.vue'
|
||||
|
||||
const visible = defineModel<boolean>()
|
||||
const emit = defineEmits<{ select: [icon: string] }>()
|
||||
|
||||
const search = ref('')
|
||||
const tab = ref<'ep' | 'tabler' | 'svg' | 'other'>('ep')
|
||||
|
||||
// ==================== Element Plus 图标 ====================
|
||||
const epIcons = Object.keys(ElementPlusIconsVue)
|
||||
|
||||
const epFiltered = computed(() => {
|
||||
if (!search.value) return epIcons
|
||||
return epIcons.filter(i => i.toLowerCase().includes(search.value.toLowerCase()))
|
||||
})
|
||||
|
||||
// ==================== Tabler Icons (通过 Iconify) ====================
|
||||
// Tabler 图标集前缀为 "tabler:"
|
||||
// 这里列出常用的 Tabler 图标,实际使用时可以从 @iconify-json/tabler 获取完整列表
|
||||
const tablerIcons = [
|
||||
'tabler:home', 'tabler:settings', 'tabler:user', 'tabler:users',
|
||||
'tabler:file', 'tabler:folder', 'tabler:mail', 'tabler:bell',
|
||||
'tabler:search', 'tabler:plus', 'tabler:edit', 'tabler:trash',
|
||||
'tabler:check', 'tabler:x', 'tabler:arrow-left', 'tabler:arrow-right',
|
||||
'tabler:arrow-up', 'tabler:arrow-down', 'tabler:chevron-left',
|
||||
'tabler:chevron-right', 'tabler:chevron-up', 'tabler:chevron-down',
|
||||
'tabler:menu', 'tabler:dots', 'tabler:dots-vertical',
|
||||
'tabler:refresh', 'tabler:reload', 'tabler:download', 'tabler:upload',
|
||||
'tabler:share', 'tabler:link', 'tabler:external-link',
|
||||
'tabler:lock', 'tabler:lock-open', 'tabler:key', 'tabler:shield',
|
||||
'tabler:eye', 'tabler:eye-off', 'tabler:copy', 'tabler:clipboard',
|
||||
'tabler:calendar', 'tabler:clock', 'tabler:timer', 'tabler:history',
|
||||
'tabler:chart-bar', 'tabler:chart-line', 'tabler:chart-pie',
|
||||
'tabler:trending-up', 'tabler:trending-down', 'tabler:activity',
|
||||
'tabler:dashboard', 'tabler:gauge', 'tabler:speedboat',
|
||||
'tabler:building', 'tabler:building-store', 'tabler:building-bank',
|
||||
'tabler:briefcase', 'tabler:certificate', 'tabler:award',
|
||||
'tabler:star', 'tabler:star-off', 'tabler:heart', 'tabler:thumb-up',
|
||||
'tabler:message', 'tabler:messages', 'tabler:notification',
|
||||
'tabler:device-desktop', 'tabler:device-mobile', 'tabler:devices',
|
||||
'tabler:wifi', 'tabler:bluetooth', 'tabler:cloud', 'tabler:database',
|
||||
'tabler:server', 'tabler:box', 'tabler:package', 'tabler:truck',
|
||||
'tabler:map', 'tabler:map-pin', 'tabler:location',
|
||||
'tabler:phone', 'tabler:video', 'tabler:camera', 'tabler:photo',
|
||||
'tabler:music', 'tabler:volume', 'tabler:microphone',
|
||||
'tabler:book', 'tabler:bookmark', 'tabler:flag', 'tabler:tag',
|
||||
'tabler:tags', 'tabler:label', 'tabler:pin',
|
||||
'tabler:brush', 'tabler:palette', 'tabler:color-swatch',
|
||||
'tabler:tools', 'tabler:tool', 'tabler:hammer', 'tabler:wrench',
|
||||
'tabler:bug', 'tabler:code', 'tabler:terminal', 'tabler:prompt',
|
||||
'tabler:git-branch', 'tabler:git-commit', 'tabler:git-merge',
|
||||
'tabler:layout', 'tabler:layout-grid', 'tabler:layout-list',
|
||||
'tabler:template', 'tabler:components', 'tabler:puzzle',
|
||||
'tabler:adjustments', 'tabler:slider', 'tabler:toggle-left',
|
||||
'tabler:filter', 'tabler:sort-ascending', 'tabler:sort-descending',
|
||||
'tabler:zoom-in', 'tabler:zoom-out', 'tabler:maximize', 'tabler:minimize',
|
||||
'tabler:login', 'tabler:logout', 'tabler:user-plus', 'tabler:user-minus',
|
||||
'tabler:user-check', 'tabler:user-x', 'tabler:user-circle',
|
||||
'tabler:id', 'tabler:passport', 'tabler:fingerprint',
|
||||
'tabler:credit-card', 'tabler:wallet', 'tabler:cash', 'tabler:coin',
|
||||
'tabler:receipt', 'tabler:file-invoice', 'tabler:report',
|
||||
'tabler:printer', 'tabler:scan', 'tabler:qrcode',
|
||||
'tabler:shopping-cart', 'tabler:basket', 'tabler:bag',
|
||||
'tabler:rocket', 'tabler:flame', 'tabler:bolt', 'tabler:bulb',
|
||||
'tabler:help', 'tabler:info-circle', 'tabler:alert-circle',
|
||||
'tabler:alert-triangle', 'tabler:circle-check', 'tabler:circle-x',
|
||||
'tabler:ban', 'tabler:plus-circle', 'tabler:minus-circle',
|
||||
'tabler:exchange', 'tabler:transfer', 'tabler:send', 'tabler:mail-forward',
|
||||
'tabler:inbox', 'tabler:archive', 'tabler:trash-off',
|
||||
'tabler:undo', 'tabler:redo', 'tabler:rotate', 'tabler:rotate-clockwise',
|
||||
'tabler:layers', 'tabler:layer', 'tabler:stack',
|
||||
'tabler:affiliate', 'tabler:apps', 'tabler:grid',
|
||||
'tabler:world', 'tabler:globe', 'tabler:language',
|
||||
'tabler:sun', 'tabler:moon', 'tabler:brightness',
|
||||
'tabler: Temperature', 'tabler:wind', 'tabler:droplet',
|
||||
'tabler:leaf', 'tabler:plant', 'tabler:tree',
|
||||
'tabler:anchor', 'tabler:ship', 'tabler:plane',
|
||||
'tabler:train', 'tabler:bus', 'tabler:car',
|
||||
'tabler:bike', 'tabler:walk', 'tabler:run',
|
||||
'tabler:school', 'tabler:book-2', 'tabler:notebook',
|
||||
'tabler:notes', 'tabler:clipboard-list', 'tabler:clipboard-check',
|
||||
'tabler:list', 'tabler:list-check', 'tabler:list-details',
|
||||
'tabler:checklist', 'tabler:task', 'tabler:tasks',
|
||||
'tabler:kanban', 'tabler:gantt', 'tabler:timeline',
|
||||
'tabler:chart-arcs', 'tabler:chart-dots', 'tabler:chart-circles',
|
||||
'tabler: pollution', 'tabler:recycle', 'tabler:eco',
|
||||
'tabler:binary', 'tabler:cpu', 'tabler:memory',
|
||||
'tabler:network', 'tabler:router', 'tabler:firewall',
|
||||
'tabler:shield-check', 'tabler:shield-lock', 'tabler:shield-off',
|
||||
'tabler:key-off', 'tabler:lock-access',
|
||||
'tabler:subtask', 'tabler:parentheses', 'tabler:braces',
|
||||
'tabler:quote', 'tabler:blockquote',
|
||||
'tabler:math', 'tabler:calculator', 'tabler:abacus',
|
||||
'tabler:clock-hour-1', 'tabler:clock-hour-2', 'tabler:clock-hour-3',
|
||||
'tabler:calendar-event', 'tabler:calendar-plus', 'tabler:calendar-minus',
|
||||
'tabler:clock-play', 'tabler:clock-pause', 'tabler:clock-stop',
|
||||
'tabler:alarm', 'tabler:stopwatch', 'tabler:hourglass',
|
||||
]
|
||||
|
||||
const tablerFiltered = computed(() => {
|
||||
if (!search.value) return tablerIcons
|
||||
return tablerIcons.filter(i => i.toLowerCase().includes(search.value.toLowerCase()))
|
||||
})
|
||||
|
||||
// ==================== SVG 示例 ====================
|
||||
const svgIcons = [
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>',
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>',
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>',
|
||||
]
|
||||
|
||||
function select(icon: string) {
|
||||
emit('select', icon)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog class="rui-dialog" v-model="visible" title="选择图标" width="800px">
|
||||
<el-tabs v-model="tab">
|
||||
<!-- Element Plus 图标 -->
|
||||
<el-tab-pane label="Element Plus" name="ep">
|
||||
<el-input v-model.trim="search" placeholder="搜索图标名..." clearable class="mb-3">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="icon-grid">
|
||||
<div v-for="icon in epFiltered" :key="icon" class="icon-cell" @click="select(icon)">
|
||||
<RuiIcon :icon="icon" :size="24" />
|
||||
<span class="text-xs mt-1 truncate w-full text-center">{{ icon }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="epFiltered.length === 0" class="text-center text-gray-400 py-8">
|
||||
未找到匹配的图标
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tabler Icons -->
|
||||
<el-tab-pane :label="`Tabler (${tablerIcons.length})`" name="tabler">
|
||||
<el-input v-model.trim="search" placeholder="搜索 Tabler 图标..." clearable class="mb-3">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="icon-grid">
|
||||
<div v-for="icon in tablerFiltered" :key="icon" class="icon-cell" @click="select(icon)">
|
||||
<Icon :icon="icon" width="24" height="24" />
|
||||
<span class="text-xs mt-1 truncate w-full text-center">{{ icon.replace('tabler:', '') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tablerFiltered.length === 0" class="text-center text-gray-400 py-8">
|
||||
未找到匹配的图标
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- SVG -->
|
||||
<el-tab-pane label="SVG" name="svg">
|
||||
<div class="icon-grid">
|
||||
<div v-for="(svg, i) in svgIcons" :key="i" class="icon-cell" @click="select(svg)">
|
||||
<RuiIcon :icon="svg" :size="24" />
|
||||
<span class="text-xs mt-1">SVG {{ i + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 图片/URL -->
|
||||
<el-tab-pane label="图片/URL" name="other">
|
||||
<div class="p-4 text-center text-gray-400 text-sm">
|
||||
粘贴图片 URL 到输入框即可使用
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 8px;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.icon-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.icon-cell:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.icon-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
icon?: string // ep icon name, iconify name, svg string, font class, or image url
|
||||
size?: number
|
||||
color?: string
|
||||
}>()
|
||||
|
||||
const type = computed<'ep' | 'iconify' | 'svg' | 'font' | 'img' | 'none'>(() => {
|
||||
if (!props.icon) return 'none'
|
||||
if (props.icon.startsWith('<svg') || props.icon.startsWith('<?xml')) return 'svg'
|
||||
if (props.icon.startsWith('http') || props.icon.startsWith('/')) return 'img'
|
||||
if (props.icon.includes(' ')) return 'font'
|
||||
// Iconify 格式:包含冒号,如 tabler:settings
|
||||
if (props.icon.includes(':')) return 'iconify'
|
||||
return 'ep'
|
||||
})
|
||||
|
||||
const style = computed(() => ({
|
||||
fontSize: (props.size || 16) + 'px',
|
||||
color: props.color,
|
||||
width: (props.size || 16) + 'px',
|
||||
height: (props.size || 16) + 'px',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="type === 'ep'" class="rui-icon ep"><el-icon :size="size" :color="color"><component :is="icon" /></el-icon></span>
|
||||
<span v-else-if="type === 'iconify' && icon" class="rui-icon iconify" :style="style"><Icon :icon="icon" :width="size || 16" :height="size || 16" :color="color" /></span>
|
||||
<span v-else-if="type === 'svg'" class="rui-icon svg" :style="style" v-html="icon" />
|
||||
<span v-else-if="type === 'font'" class="rui-icon font" :style="style" :class="icon" />
|
||||
<img v-else-if="type === 'img'" class="rui-icon img" :src="icon" :style="style" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rui-icon { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; }
|
||||
.rui-icon.svg :deep(svg) { width: 100%; height: 100%; }
|
||||
.rui-icon.img { object-fit: contain; }
|
||||
</style>
|
||||
@@ -0,0 +1,752 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { RefreshRight, ArrowDown, Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 数据类型,用于自动格式化
|
||||
*/
|
||||
export type DataType =
|
||||
| 'dateTime' // 日期时间:yyyy-MM-dd HH:mm:ss
|
||||
| 'date' // 日期:yyyy-MM-dd
|
||||
| 'time' // 时间:HH:mm:ss
|
||||
| 'number' // 数字:千分位
|
||||
| 'money' // 金额:保留2位小数
|
||||
| 'percent' // 百分比
|
||||
| 'enum' // 枚举:配合 enumMap 使用
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
export interface TableColumn {
|
||||
/** 字段名 */
|
||||
prop: string
|
||||
/** 列标题 */
|
||||
label: string
|
||||
/** 列宽度 */
|
||||
width?: string | number
|
||||
/** 最小宽度 */
|
||||
minWidth?: string | number
|
||||
/** 固定位置 */
|
||||
fixed?: 'left' | 'right'
|
||||
/** 是否可排序 */
|
||||
sortable?: boolean | 'custom'
|
||||
/** 对齐方式 */
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** 数据类型,用于自动格式化(优先级高于 formatter) */
|
||||
dataType?: DataType
|
||||
/** 枚举映射(dataType='enum' 时使用) */
|
||||
enumMap?: Record<string | number, string>
|
||||
/** 格式化函数(dataType 无法满足时使用) */
|
||||
formatter?: (row: any, column: any, cellValue: any, index: number) => string
|
||||
/** 是否使用自定义 slot(slot 名为 column-{prop}) */
|
||||
slot?: boolean
|
||||
/** 是否隐藏 */
|
||||
hide?: boolean
|
||||
/** 是否显示提示(show-overflow-tooltip) */
|
||||
tooltip?: boolean
|
||||
/** 是否可导出 */
|
||||
exportable?: boolean
|
||||
/** 是否可编辑 */
|
||||
editable?: boolean
|
||||
/** 编辑类型 input/select/switch/number */
|
||||
editType?: 'input' | 'select' | 'switch' | 'number'
|
||||
/** 编辑选项(editType='select' 时使用) */
|
||||
editOptions?: { label: string; value: any }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询结果
|
||||
*/
|
||||
export interface PageResult<T = any> {
|
||||
/** 列表数据 */
|
||||
list: T[]
|
||||
/** 总记录数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页参数
|
||||
*/
|
||||
export interface PageParams {
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序参数
|
||||
*/
|
||||
export interface SortParams {
|
||||
sortField?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 列配置 */
|
||||
columns: TableColumn[]
|
||||
/** 数据加载函数,接收分页参数,返回 Promise<PageResult> */
|
||||
loadData: (params: PageParams & Record<string, any>) => Promise<PageResult>
|
||||
/** 是否立即加载 */
|
||||
immediate?: boolean
|
||||
/** 行 key */
|
||||
rowKey?: string
|
||||
/** 显示序号列 */
|
||||
showIndex?: boolean
|
||||
/** 显示选择列 */
|
||||
showSelection?: boolean
|
||||
/** 显示工具栏 */
|
||||
showToolbar?: boolean
|
||||
/** 默认分页大小 */
|
||||
defaultPageSize?: number
|
||||
/** 分页大小选项 */
|
||||
pageSizes?: number[]
|
||||
/** 是否支持导出 */
|
||||
exportable?: boolean
|
||||
/** 导出文件名 */
|
||||
exportFilename?: string
|
||||
/** 是否支持导入 */
|
||||
importable?: boolean
|
||||
/** 导入接口地址 */
|
||||
importUrl?: string
|
||||
}>(), {
|
||||
immediate: true,
|
||||
rowKey: 'id',
|
||||
showIndex: true,
|
||||
showSelection: false,
|
||||
showToolbar: true,
|
||||
defaultPageSize: 10,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
exportable: false,
|
||||
exportFilename: '数据导出',
|
||||
importable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 刷新后触发 */
|
||||
(e: 'refresh', data: any[]): void
|
||||
/** 选择变化 */
|
||||
(e: 'selection-change', rows: any[]): void
|
||||
/** 行内保存 */
|
||||
(e: 'row-save', row: any): void
|
||||
}>()
|
||||
|
||||
// ==================== 导入 ====================
|
||||
|
||||
/** 导入弹窗显示状态 */
|
||||
const importVisible = ref(false)
|
||||
|
||||
/** 导入文件 */
|
||||
const importFile = ref<File | null>(null)
|
||||
|
||||
/** 导入加载状态 */
|
||||
const importLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 处理导入
|
||||
*/
|
||||
async function handleImport() {
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
importLoading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile.value)
|
||||
const res: any = await request({
|
||||
url: props.importUrl || '',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res.error === 0) {
|
||||
ElMessage.success(`导入成功,共 ${res.data} 条记录`)
|
||||
importVisible.value = false
|
||||
importFile.value = null
|
||||
load()
|
||||
} else {
|
||||
ElMessage.error(res.message || '导入失败')
|
||||
}
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
// 分页参数
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: props.defaultPageSize,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 查询参数(由外部通过 search slot 填充)
|
||||
const queryParams = reactive<Record<string, any>>({})
|
||||
|
||||
// 排序参数
|
||||
const sortParams = reactive<SortParams>({
|
||||
sortField: undefined,
|
||||
sortOrder: undefined,
|
||||
})
|
||||
|
||||
// 列显示控制
|
||||
const columnVisibility = reactive<Record<string, boolean>>({})
|
||||
|
||||
// 初始化列显示状态
|
||||
function initColumnVisibility() {
|
||||
props.columns.forEach(col => {
|
||||
if (columnVisibility[col.prop] === undefined) {
|
||||
columnVisibility[col.prop] = !col.hide
|
||||
}
|
||||
})
|
||||
}
|
||||
initColumnVisibility()
|
||||
|
||||
// ==================== 行内编辑 ====================
|
||||
|
||||
/** 当前正在编辑的行 ID */
|
||||
const editingRowId = ref<string | number | null>(null)
|
||||
|
||||
/** 编辑中的行数据(深拷贝) */
|
||||
const editingRowData = ref<any>({})
|
||||
|
||||
/**
|
||||
* 进入编辑模式
|
||||
*/
|
||||
function startEdit(row: any) {
|
||||
editingRowId.value = row[props.rowKey]
|
||||
editingRowData.value = JSON.parse(JSON.stringify(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存编辑
|
||||
*/
|
||||
function saveEdit(row: any) {
|
||||
emit('row-save', editingRowData.value)
|
||||
editingRowId.value = null
|
||||
editingRowData.value = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消编辑
|
||||
*/
|
||||
function cancelEdit() {
|
||||
editingRowId.value = null
|
||||
editingRowData.value = {}
|
||||
}
|
||||
|
||||
// 可见列(考虑用户自定义显示/隐藏)
|
||||
const visibleColumns = computed(() => {
|
||||
return props.columns.filter(c => columnVisibility[c.prop] !== false)
|
||||
})
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
...queryParams,
|
||||
}
|
||||
// 添加排序参数
|
||||
if (sortParams.sortField && sortParams.sortOrder) {
|
||||
params.sortField = sortParams.sortField
|
||||
params.sortOrder = sortParams.sortOrder
|
||||
}
|
||||
const res = await props.loadData(params)
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total || 0
|
||||
emit('refresh', tableData.value)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索(重置到第一页)
|
||||
*/
|
||||
function search() {
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置(清空查询参数并加载)
|
||||
*/
|
||||
function reset() {
|
||||
Object.keys(queryParams).forEach(key => delete queryParams[key])
|
||||
// 重置排序
|
||||
sortParams.sortField = undefined
|
||||
sortParams.sortOrder = undefined
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新(保持当前页)
|
||||
*/
|
||||
function refresh() {
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页大小变化
|
||||
*/
|
||||
function handleSizeChange(val: number) {
|
||||
pagination.size = val
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 页码变化
|
||||
*/
|
||||
function handleCurrentChange(val: number) {
|
||||
pagination.page = val
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序变化
|
||||
*/
|
||||
function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
|
||||
if (order) {
|
||||
sortParams.sortField = prop
|
||||
sortParams.sortOrder = order === 'ascending' ? 'asc' : 'desc'
|
||||
} else {
|
||||
sortParams.sortField = undefined
|
||||
sortParams.sortOrder = undefined
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择变化
|
||||
*/
|
||||
function handleSelectionChange(rows: any[]) {
|
||||
emit('selection-change', rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置查询参数(供外部调用)
|
||||
*/
|
||||
function setQueryParams(params: Record<string, any>) {
|
||||
Object.assign(queryParams, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换列显示/隐藏
|
||||
*/
|
||||
function toggleColumn(prop: string, visible: boolean) {
|
||||
columnVisibility[prop] = visible
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 CSV
|
||||
*/
|
||||
function exportData() {
|
||||
if (!tableData.value.length) {
|
||||
ElMessage.warning('暂无数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
// 表头
|
||||
const headers = visibleColumns.value
|
||||
.filter(col => col.exportable !== false)
|
||||
.map(col => col.label)
|
||||
|
||||
// 数据行
|
||||
const rows = tableData.value.map(row => {
|
||||
return visibleColumns.value
|
||||
.filter(col => col.exportable !== false)
|
||||
.map(col => {
|
||||
const val = row[col.prop]
|
||||
const formatter = getFormatter(col)
|
||||
if (formatter) {
|
||||
return formatter(row, col, val, 0)
|
||||
}
|
||||
return val ?? ''
|
||||
})
|
||||
})
|
||||
|
||||
// 构建 CSV 内容
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row =>
|
||||
row.map(cell => {
|
||||
const str = String(cell).replace(/"/g, '""')
|
||||
return `"${str}"`
|
||||
}).join(',')
|
||||
),
|
||||
].join('\n')
|
||||
|
||||
// 下载文件
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${props.exportFilename}_${new Date().toLocaleDateString()}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 dataType 获取 formatter 函数
|
||||
*/
|
||||
function getFormatter(col: TableColumn): ((row: any, column: any, cellValue: any, index: number) => string) | undefined {
|
||||
// 如果已定义 formatter,优先使用
|
||||
if (col.formatter) {
|
||||
return col.formatter
|
||||
}
|
||||
|
||||
// 根据 dataType 自动格式化
|
||||
switch (col.dataType) {
|
||||
case 'dateTime':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (!val) return '-'
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? String(val) : d.toLocaleString()
|
||||
}
|
||||
case 'date':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (!val) return '-'
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? String(val) : d.toLocaleDateString()
|
||||
}
|
||||
case 'time':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (!val) return '-'
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? String(val) : d.toLocaleTimeString()
|
||||
}
|
||||
case 'number':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (val == null) return '-'
|
||||
const num = Number(val)
|
||||
return isNaN(num) ? String(val) : num.toLocaleString()
|
||||
}
|
||||
case 'money':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (val == null) return '-'
|
||||
const num = Number(val)
|
||||
return isNaN(num) ? String(val) : '¥' + num.toFixed(2)
|
||||
}
|
||||
case 'percent':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (val == null) return '-'
|
||||
const num = Number(val)
|
||||
return isNaN(num) ? String(val) : (num * 100).toFixed(2) + '%'
|
||||
}
|
||||
case 'enum':
|
||||
return (_row: any, _column: any, val: any) => {
|
||||
if (val == null) return '-'
|
||||
return col.enumMap?.[val] ?? String(val)
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
load,
|
||||
search,
|
||||
reset,
|
||||
refresh,
|
||||
setQueryParams,
|
||||
startEdit,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.immediate) {
|
||||
load()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rui-table">
|
||||
<!-- 查询区域 -->
|
||||
<div v-if="$slots.search" class="search-area">
|
||||
<el-form :model="queryParams" inline>
|
||||
<slot name="search" :query="queryParams" :search="search" :reset="reset" />
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div v-if="showToolbar" class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<slot name="toolbar-left" />
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<slot name="toolbar-right" />
|
||||
|
||||
<!-- 列显示控制 -->
|
||||
<el-dropdown v-if="columns.length > 0" trigger="click">
|
||||
<el-button :icon="ArrowDown" circle size="small" title="列设置" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="col in columns"
|
||||
:key="col.prop"
|
||||
:divided="false"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="columnVisibility[col.prop]"
|
||||
@update:model-value="(val) => toggleColumn(col.prop, val as boolean)"
|
||||
>
|
||||
{{ col.label }}
|
||||
</el-checkbox>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 导入按钮 -->
|
||||
<el-button
|
||||
v-if="importable"
|
||||
type="warning"
|
||||
circle
|
||||
size="small"
|
||||
title="导入"
|
||||
@click="importVisible = true"
|
||||
>
|
||||
<el-icon><Upload /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<!-- 导出按钮 -->
|
||||
<el-button
|
||||
v-if="exportable"
|
||||
type="success"
|
||||
circle
|
||||
size="small"
|
||||
title="导出"
|
||||
@click="exportData"
|
||||
>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button :icon="RefreshRight" circle size="small" title="刷新" @click="refresh" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
:row-key="rowKey"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<!-- 选择列 -->
|
||||
<el-table-column v-if="showSelection" type="selection" width="50" align="center" />
|
||||
|
||||
<!-- 序号列 -->
|
||||
<el-table-column v-if="showIndex" type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<!-- 动态列 -->
|
||||
<template v-for="col in visibleColumns" :key="col.prop">
|
||||
<!-- 自定义 slot 列 -->
|
||||
<el-table-column
|
||||
v-if="col.slot"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:fixed="col.fixed"
|
||||
:sortable="col.sortable"
|
||||
:align="col.align || 'left'"
|
||||
:show-overflow-tooltip="col.tooltip"
|
||||
>
|
||||
<template #default="scope">
|
||||
<slot :name="`column-${col.prop}`" v-bind="scope" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 普通列(支持行内编辑) -->
|
||||
<el-table-column
|
||||
v-else
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:fixed="col.fixed"
|
||||
:sortable="col.sortable"
|
||||
:align="col.align || 'left'"
|
||||
:show-overflow-tooltip="col.tooltip"
|
||||
>
|
||||
<template #default="scope">
|
||||
<!-- 编辑模式 -->
|
||||
<template v-if="col.editable && editingRowId === scope.row[rowKey]">
|
||||
<el-input
|
||||
v-if="!col.editType || col.editType === 'input'"
|
||||
v-model="editingRowData[col.prop]"
|
||||
size="small"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="col.editType === 'number'"
|
||||
v-model="editingRowData[col.prop]"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="col.editType === 'select'"
|
||||
v-model="editingRowData[col.prop]"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in col.editOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-switch
|
||||
v-else-if="col.editType === 'switch'"
|
||||
v-model="editingRowData[col.prop]"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
<!-- 查看模式 -->
|
||||
<template v-else>
|
||||
{{ getFormatter(col)?.(scope.row, col, scope.row[col.prop], scope.$index) ?? scope.row[col.prop] }}
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<el-table-column label="操作" fixed="right" min-width="120" align="center">
|
||||
<template #default="scope">
|
||||
<!-- 行内编辑操作 -->
|
||||
<template v-if="columns.some(c => c.editable)">
|
||||
<template v-if="editingRowId === scope.row[rowKey]">
|
||||
<el-button link type="primary" size="small" @click="saveEdit(scope.row)">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button link size="small" @click="cancelEdit">
|
||||
取消
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" @click="startEdit(scope.row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<slot name="action" v-bind="scope" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="action" v-bind="scope" />
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-area">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:page-sizes="pageSizes"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 导入弹窗 -->
|
||||
<el-dialog
|
||||
v-model="importVisible"
|
||||
title="导入数据"
|
||||
width="500px"
|
||||
class="rui-dialog"
|
||||
>
|
||||
<el-upload
|
||||
drag
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:on-change="(file: any) => importFile = file.raw"
|
||||
accept=".xlsx,.xls"
|
||||
:limit="1"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
拖拽文件到此处或 <em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
仅支持 .xlsx / .xls 格式
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importLoading" @click="handleImport">
|
||||
确认导入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rui-table {
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-area {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import {watch, ref, nextTick} from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {useTagsStore} from '@/stores/tags'
|
||||
import {useAppStore} from '@/stores/app'
|
||||
import {storeToRefs} from 'pinia'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tags = useTagsStore()
|
||||
const app = useAppStore()
|
||||
const { t } = useI18n()
|
||||
const {visited} = storeToRefs(tags)
|
||||
|
||||
watch(() => route.path, () => tags.addTag(route), {immediate: true})
|
||||
|
||||
function handleClose(path: string, index: number) {
|
||||
tags.removeTag(path)
|
||||
if (route.path === path) {
|
||||
const prev = visited.value[index - 1] || visited.value[index]
|
||||
if (prev) router.push(prev.path)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommand(cmd: string, path: string, index: number) {
|
||||
if (cmd === 'close') handleClose(path, index)
|
||||
if (cmd === 'refresh') {
|
||||
// 通过 query 参数触发重新渲染
|
||||
router.replace({ path, query: { _t: String(Date.now()) } })
|
||||
}
|
||||
if (cmd === 'fullscreen') {
|
||||
if (route.path !== path) router.push(path) // 先切换到目标页面
|
||||
nextTick(() => app.togglePageFullscreen()) // 等页面切换后再全屏
|
||||
}
|
||||
if (cmd === 'closeOther') tags.closeOther(path)
|
||||
if (cmd === 'closeAll') { tags.closeAll(); router.push('/dashboard') }
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
const contextMenu = ref({ visible: false, x: 0, y: 0, path: '', index: 0 })
|
||||
|
||||
function showContextMenu(e: MouseEvent, path: string, index: number) {
|
||||
e.preventDefault()
|
||||
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, path, index }
|
||||
}
|
||||
|
||||
function hideMenu() {
|
||||
contextMenu.value.visible = false
|
||||
}
|
||||
|
||||
function scrollTags(dir: number) {
|
||||
const wrap = document.querySelector('.tags-bar .el-scrollbar__wrap') as HTMLElement
|
||||
if (wrap) wrap.scrollBy({ left: dir * 200, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags-bar">
|
||||
<span class="tag-arrow" @click="scrollTags(-1)"><el-icon :size="16"><ArrowLeft /></el-icon></span>
|
||||
<el-scrollbar>
|
||||
<div class="tags-list">
|
||||
<div
|
||||
v-for="(tag, i) in visited" :key="tag.path"
|
||||
:class="['tag', { active: route.path === tag.path }]"
|
||||
@click="router.push(tag.path)"
|
||||
@contextmenu.prevent="showContextMenu($event, tag.path, i)"
|
||||
>
|
||||
<div class="tag-wrap">
|
||||
<span class="tag-text">{{ t(tag.title) || tag.title }}</span>
|
||||
<span
|
||||
v-if="tag.path !== '/dashboard'"
|
||||
class="tag-close"
|
||||
@click.stop="handleClose(tag.path, i)"
|
||||
><el-icon :size="12"><Close/></el-icon></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<span class="tag-arrow" @click="scrollTags(1)"><el-icon :size="16"><ArrowRight /></el-icon></span>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="context-menu"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="context-item" @click="handleCommand('close', contextMenu.path, contextMenu.index)">{{ t('context.close') }}</div>
|
||||
<div class="context-item" @click="handleCommand('refresh', contextMenu.path, contextMenu.index)">{{ t('context.refresh') }}</div>
|
||||
<div class="context-item" @click="handleCommand('fullscreen', contextMenu.path, contextMenu.index)">{{ t('context.fullscreen') }}</div>
|
||||
<div class="context-divider" />
|
||||
<div class="context-item" @click="handleCommand('closeOther', contextMenu.path, contextMenu.index)">{{ t('context.closeOther') }}</div>
|
||||
<div class="context-item" @click="handleCommand('closeAll', contextMenu.path, contextMenu.index)">{{ t('context.closeAll') }}</div>
|
||||
</div>
|
||||
<div v-if="contextMenu.visible" class="context-overlay" @click="hideMenu" />
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tags-bar {
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
position: relative; z-index: 4; display: flex; align-items: center;
|
||||
padding-top: 22px;
|
||||
}
|
||||
html.dark .tags-bar { border-color: #2a2a2a; }
|
||||
.tags-bar .el-scrollbar__wrap {
|
||||
overflow-x:auto !important
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding:0 15px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
cursor: pointer;
|
||||
justify-content: space-between;
|
||||
height: 26px;
|
||||
|
||||
border-width: 1px 27px 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
margin: 0 -15px;
|
||||
}
|
||||
|
||||
|
||||
.tag:hover {
|
||||
border-color:var(--el-color-primary-light-5);
|
||||
background-color: var(--el-color-primary-light-5);
|
||||
color:unset;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.active, .tag:hover {
|
||||
-webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg==), url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
|
||||
-webkit-mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
|
||||
-webkit-mask-position: right bottom, left bottom, center top;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg==), url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
|
||||
mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
|
||||
mask-position: right bottom, left bottom, center top;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
border-radius: 100%;
|
||||
width: 14px; height: 14px; text-align: center; line-height: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0.6; transition: all 0.15s;
|
||||
}
|
||||
.tag.active .tag-close { color: rgba(255,255,255,0.7); }
|
||||
.tag-close:hover { opacity: 1; background-color: var(--el-color-primary-light-3); color: #fff; }
|
||||
|
||||
|
||||
.tag-wrap{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center ;
|
||||
}
|
||||
.tag:hover {
|
||||
color:unset;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tags-list .active {
|
||||
color: var(--el-color-white);
|
||||
border-color: var(--el-color-primary);
|
||||
z-index: 1;
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.tag-arrow {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: 4px; cursor: pointer;
|
||||
color: #999; flex-shrink: 0; transition: all 0.15s;
|
||||
}
|
||||
.tag-arrow:hover { color: #333; background: rgba(0,0,0,0.06); }
|
||||
html.dark .tag-arrow:hover { color: #ccc; background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* 右键菜单 */
|
||||
.context-menu {
|
||||
position: fixed; z-index: 9999;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
||||
padding: 4px 0; min-width: 100px;
|
||||
}
|
||||
.context-item {
|
||||
padding: 8px 16px; font-size: 13px; cursor: pointer; color: #333;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.context-item:hover { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
|
||||
.context-divider { height: 1px; margin: 4px 8px; background: var(--el-border-color-light); }
|
||||
.context-overlay { position: fixed; inset: 0; z-index: 9998; }
|
||||
html.dark .context-item { color: #ccc; }
|
||||
html.dark .context-item:hover { background: rgba(64,158,255,0.1); }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const visible = defineModel<boolean>()
|
||||
const app = useAppStore()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const colors = ['#1677ff', '#409eff', '#52c41a', '#fa8c16', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96']
|
||||
|
||||
function onLangChange(v: string) {
|
||||
locale.value = v // 立即响应,无需刷新
|
||||
app.setLang(v)
|
||||
}
|
||||
|
||||
function onLayoutChange(v: string) {
|
||||
app.setLayout(v)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="t('theme.title')" size="280px">
|
||||
<!-- 暗黑模式 -->
|
||||
<div class="mb-5">
|
||||
<p class="text-sm mb-2 text-gray-500">{{ t('theme.dark') }}</p>
|
||||
<el-switch :model-value="app.dark" @change="app.toggleDark()" :active-text="app.dark ? '🌙' : '☀️'" />
|
||||
</div>
|
||||
|
||||
<!-- 语言 -->
|
||||
<div class="mb-5">
|
||||
<p class="text-sm mb-2 text-gray-500">语言 / Language</p>
|
||||
<el-radio-group :model-value="locale" @change="onLangChange">
|
||||
<el-radio-button value="zh-CN">中文</el-radio-button>
|
||||
<el-radio-button value="en-US">English</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 布局 -->
|
||||
<div class="mb-5">
|
||||
<p class="text-sm mb-2 text-gray-500">{{ t('theme.layout') }}</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="l in (['side', 'top', 'mix', 'double'] as const)"
|
||||
:key="l"
|
||||
:class="['p-2 rounded text-center text-xs cursor-pointer border-2', app.layout === l ? 'border-blue-500 bg-blue-50' : 'border-gray-200']"
|
||||
@click="onLayoutChange(l)"
|
||||
>
|
||||
{{ t(`theme.layout${l.charAt(0).toUpperCase() + l.slice(1)}` as any) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色 -->
|
||||
<div>
|
||||
<p class="text-sm mb-2 text-gray-500">{{ t('theme.color') }}</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<div
|
||||
v-for="c in colors"
|
||||
:key="c"
|
||||
:style="{ background: c }"
|
||||
:class="['w-6 h-6 rounded-full cursor-pointer', app.primaryColor === c ? 'ring-2 ring-offset-2 ring-blue-500' : '']"
|
||||
@click="app.setPrimaryColor(c)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { menuService } from '@/service/system/menuService'
|
||||
|
||||
/**
|
||||
* 图标名称映射
|
||||
* - 如果图标以 "tabler:" 开头,直接透传(使用 Iconify 渲染)
|
||||
* - 否则映射为 Element Plus 图标组件名
|
||||
*/
|
||||
function getIconName(icon?: string): string {
|
||||
if (!icon) return 'FolderOpened'
|
||||
// Tabler 图标直接透传
|
||||
if (icon.startsWith('tabler:')) return icon
|
||||
// Element Plus 图标映射
|
||||
const iconMap: Record<string, string> = {
|
||||
'setting': 'Setting',
|
||||
'user': 'UserFilled',
|
||||
'monitor': 'Monitor',
|
||||
'document': 'Document',
|
||||
'edit': 'Edit',
|
||||
'present': 'Present',
|
||||
'tools': 'Tools',
|
||||
'home': 'HomeFilled',
|
||||
}
|
||||
const normalized = icon.charAt(0).toUpperCase() + icon.slice(1).toLowerCase()
|
||||
return iconMap[normalized.toLowerCase()] || normalized || 'FolderOpened'
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端菜单转换为前端格式
|
||||
*/
|
||||
function transformMenu(menu: any): any {
|
||||
const item: any = {
|
||||
id: menu.id,
|
||||
title: menu.menuName,
|
||||
icon: getIconName(menu.icon),
|
||||
path: menu.path,
|
||||
}
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const validChildren = menu.children.filter((child: any) => child.menuType !== 3)
|
||||
if (validChildren.length > 0) {
|
||||
item.children = validChildren.map(transformMenu)
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取菜单下的第一个可跳转路径
|
||||
*/
|
||||
function getFirstPath(menu: any): string | undefined {
|
||||
if (menu.path) return menu.path
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
for (const child of menu.children) {
|
||||
const path = getFirstPath(child)
|
||||
if (path) return path
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径在菜单树中查找对应的面包屑路径
|
||||
*/
|
||||
function findBreadcrumbPath(menus: any[], targetPath: string): any[] {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === targetPath) {
|
||||
return [{ id: menu.id, title: menu.menuName, path: menu.path }]
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const childPath = findBreadcrumbPath(menu.children, targetPath)
|
||||
if (childPath.length > 0) {
|
||||
return [{ id: menu.id, title: menu.menuName, path: menu.path }, ...childPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单管理 Composable
|
||||
*/
|
||||
export function useMenu() {
|
||||
const rawMenus = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const filtered = rawMenus.value.filter((menu: any) => menu.menuType !== 3)
|
||||
return filtered.map(transformMenu)
|
||||
})
|
||||
|
||||
/**
|
||||
* 一级菜单列表(用于 MixLayout/DoubleLayout 的顶栏/图标栏)
|
||||
*/
|
||||
const topMenus = computed(() => {
|
||||
return menuItems.value.map((item: any) => ({
|
||||
id: item.id,
|
||||
key: String(item.id),
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
path: item.path || getFirstPath(item),
|
||||
children: item.children,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据一级菜单 ID 获取对应的二级菜单列表
|
||||
*/
|
||||
function getSubMenus(topMenuId: string | number): any[] {
|
||||
const topMenu = rawMenus.value.find((m: any) => String(m.id) === String(topMenuId))
|
||||
if (!topMenu || !topMenu.children) return []
|
||||
return topMenu.children
|
||||
.filter((child: any) => child.menuType !== 3)
|
||||
.map(transformMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前路径生成面包屑数据
|
||||
*/
|
||||
function getBreadcrumbs(currentPath: string): any[] {
|
||||
const paths = findBreadcrumbPath(rawMenus.value, currentPath)
|
||||
return paths.map(p => ({ id: p.id, title: p.title, path: p.path }))
|
||||
}
|
||||
|
||||
async function loadMenus(platform?: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
rawMenus.value = await menuService.userTree(platform)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
topMenus,
|
||||
rawMenus,
|
||||
loading,
|
||||
loadMenus,
|
||||
getSubMenus,
|
||||
getBreadcrumbs,
|
||||
getFirstPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import SideLayout from './SideLayout.vue'
|
||||
import TopNavLayout from './TopNavLayout.vue'
|
||||
import MixLayout from './MixLayout.vue'
|
||||
import DoubleLayout from './DoubleLayout.vue'
|
||||
import ThemeDrawer from '@/components/ThemeDrawer.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
const Component = computed(() => {
|
||||
return { side: SideLayout, top: TopNavLayout, mix: MixLayout, double: DoubleLayout }[app.layout] || SideLayout
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<component :is="Component" :key="app.layout" />
|
||||
<ThemeDrawer v-model="app.themeVisible" />
|
||||
</template>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useMenu } from '@/composables/useMenu'
|
||||
import TagsBar from '@/components/TagsBar.vue'
|
||||
import RuiIcon from '@/components/RuiIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const user = useUserStore()
|
||||
const { topMenus, loadMenus, getSubMenus, getBreadcrumbs, getFirstPath } = useMenu()
|
||||
|
||||
const activeTop = ref('')
|
||||
const subCollapsed = ref(false)
|
||||
|
||||
const subMenus = computed(() => {
|
||||
if (!activeTop.value) return []
|
||||
return getSubMenus(activeTop.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 面包屑数据
|
||||
*/
|
||||
const breadcrumbs = computed(() => {
|
||||
const crumbs = getBreadcrumbs(route.path)
|
||||
if (crumbs.length === 0) {
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
|
||||
}
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
|
||||
})
|
||||
|
||||
function onTopClick(item: any) {
|
||||
activeTop.value = item.key
|
||||
if (item.path) { router.push(item.path); return }
|
||||
const first = getFirstPath(item)
|
||||
if (first) router.push(first)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMenus('admin').then(() => {
|
||||
if (topMenus.value.length > 0 && !activeTop.value) {
|
||||
activeTop.value = topMenus.value[0].key
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="h-screen">
|
||||
<!-- 图标栏 -->
|
||||
<div class="icon-col">
|
||||
<el-icon :size="22" class="my-3 icon-logo"><Setting /></el-icon>
|
||||
<el-tooltip v-for="item in topMenus" :key="item.key" :content="item.title" placement="right">
|
||||
<div :class="['icon-btn', { active: activeTop === item.key }]" @click="onTopClick(item)">
|
||||
<RuiIcon :icon="item.icon" :size="20" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 二级菜单(可收起) -->
|
||||
<div v-if="subMenus.length" class="sub-col" :class="{ 'sub-collapsed': subCollapsed }">
|
||||
<div class="sub-col-header">
|
||||
<span v-show="!subCollapsed" class="sub-title">{{ topMenus.find(m => m.key === activeTop)?.title }}</span>
|
||||
<el-icon :size="16" class="cursor-pointer sub-toggle" @click="subCollapsed = !subCollapsed">
|
||||
<Fold v-if="!subCollapsed" /><Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<el-scrollbar>
|
||||
<el-menu v-if="!subCollapsed" :default-active="route.path" router class="sub-menu" :key="activeTop">
|
||||
<template v-for="s in subMenus" :key="s.id">
|
||||
<el-sub-menu v-if="s.children?.length" :index="String(s.id)">
|
||||
<template #title>
|
||||
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ s.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="c in s.children" :key="c.id" :index="c.path">
|
||||
<RuiIcon v-if="c.icon" :icon="c.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ c.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="s.path">
|
||||
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ s.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<el-container class="flex-1">
|
||||
<el-header class="top-bar">
|
||||
<div class="top-bar-inner">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
|
||||
{{ crumb.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<!-- 右侧工具栏 -->
|
||||
<div class="flex items-center gap-3 ml-auto">
|
||||
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
|
||||
<Moon v-if="!app.dark" /><Sunny v-else />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="主题配置" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-dropdown>
|
||||
<el-badge :value="3" :offset="[-2, 2]">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-dropdown>
|
||||
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
|
||||
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
|
||||
<span class="text-sm">{{ user.username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-icon><User /></el-icon>{{ t('common.personal') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="app.logout()">
|
||||
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<TagsBar />
|
||||
</el-header>
|
||||
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 图标栏 */
|
||||
.icon-col {
|
||||
width: 56px; background: #001529;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding-top: 4px; flex-shrink: 0;
|
||||
}
|
||||
.icon-logo { color: var(--el-color-primary); }
|
||||
.icon-btn {
|
||||
width: 40px; height: 40px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #ffffff73; cursor: pointer; transition: all 0.2s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.icon-btn:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.icon-btn.active { color: #fff; background: rgba(255,255,255,0.15); }
|
||||
|
||||
/* 二级菜单栏 */
|
||||
.sub-col {
|
||||
width: 200px; background: #fff;
|
||||
border-right: 1px solid #eee;
|
||||
flex-shrink: 0; transition: width 0.2s;
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
}
|
||||
.sub-col.sub-collapsed { width: 48px; }
|
||||
.sub-col-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px; flex-shrink: 0;
|
||||
}
|
||||
.sub-col.sub-collapsed .sub-col-header { justify-content: center; padding: 12px 0; }
|
||||
.sub-toggle { flex-shrink: 0; color: #999; }
|
||||
.sub-toggle:hover { color: #333; }
|
||||
.sub-title { font-size: 12px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
/* 二级菜单样式 */
|
||||
.sub-menu { border-right: none !important; }
|
||||
.sub-menu .el-menu-item.is-active {
|
||||
background: rgba(0,0,0,0.04);
|
||||
border-right: 3px solid var(--el-color-primary);
|
||||
color: var(--el-color-primary); font-weight: 500;
|
||||
}
|
||||
.menu-icon { margin-right: 5px; }
|
||||
|
||||
/* 顶部栏 */
|
||||
.top-bar { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.top-bar-inner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 52px; background: #fff; padding: 0 20px;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; }
|
||||
|
||||
/* 暗色模式适配 */
|
||||
html.dark .sub-col { background: #1d1d1d; border-color: #333; }
|
||||
html.dark .sub-toggle:hover { color: #ccc; }
|
||||
html.dark .top-bar-inner { background: #1d1d1d; }
|
||||
html.dark .main { background: #111; }
|
||||
html.dark .sub-menu .el-menu-item.is-active { background: rgba(255,255,255,0.06); }
|
||||
html.dark .el-breadcrumb__inner { color: #ccc; }
|
||||
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
|
||||
html.dark .el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useMenu } from '@/composables/useMenu'
|
||||
import TagsBar from '@/components/TagsBar.vue'
|
||||
import RuiIcon from '@/components/RuiIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const user = useUserStore()
|
||||
const { topMenus, loadMenus, getSubMenus, getBreadcrumbs, getFirstPath } = useMenu()
|
||||
|
||||
const activeTop = ref('')
|
||||
|
||||
const sideMenus = computed(() => {
|
||||
if (!activeTop.value) return []
|
||||
return getSubMenus(activeTop.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 面包屑数据
|
||||
*/
|
||||
const breadcrumbs = computed(() => {
|
||||
const crumbs = getBreadcrumbs(route.path)
|
||||
if (crumbs.length === 0) {
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
|
||||
}
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
|
||||
})
|
||||
|
||||
function onTopChange(key: string) {
|
||||
activeTop.value = key
|
||||
const subItems = getSubMenus(key)
|
||||
if (subItems.length > 0) {
|
||||
const firstPath = getFirstPath(subItems[0])
|
||||
if (firstPath) router.push(firstPath)
|
||||
} else {
|
||||
const topItem = topMenus.value.find((m: any) => m.key === key)
|
||||
if (topItem?.path) router.push(topItem.path)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMenus('admin').then(() => {
|
||||
if (topMenus.value.length > 0 && !activeTop.value) {
|
||||
activeTop.value = topMenus.value[0].key
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="h-screen">
|
||||
<div class="flex flex-col w-full">
|
||||
<!-- 顶部一级菜单 + 工具栏 -->
|
||||
<el-header class="top-header">
|
||||
<div class="top-header-nav">
|
||||
<div class="flex items-center gap-6 h-full">
|
||||
<!-- Logo -->
|
||||
<span class="text-lg font-bold whitespace-nowrap" :style="{ color: app.primaryColor }">{{ t('app.title') }}</span>
|
||||
<!-- 一级菜单 -->
|
||||
<div class="flex gap-0 flex-1">
|
||||
<div
|
||||
v-for="item in topMenus"
|
||||
:key="item.key"
|
||||
:class="[
|
||||
'px-3 py-2 text-sm cursor-pointer rounded transition-colors flex items-center gap-1',
|
||||
activeTop === item.key ? 'text-white' : 'hover:text-blue-500'
|
||||
]"
|
||||
:style="activeTop === item.key ? { background: app.primaryColor } : {}"
|
||||
@click="onTopChange(item.key)"
|
||||
>
|
||||
<RuiIcon v-if="item.icon" :icon="item.icon" :size="14" />
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧工具栏 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
|
||||
<Moon v-if="!app.dark" /><Sunny v-else />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="主题配置" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-dropdown>
|
||||
<el-badge :value="3" :offset="[-2, 2]">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-dropdown>
|
||||
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
|
||||
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
|
||||
<span class="text-sm">{{ user.username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-icon><User /></el-icon>{{ t('common.personal') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="app.logout()">
|
||||
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 面包屑 + 标签栏 -->
|
||||
<div class="top-header-bottom">
|
||||
<div class="breadcrumb-wrap">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
|
||||
{{ crumb.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<TagsBar />
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 二级侧边 + 内容 -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div v-if="sideMenus.length" class="side-sub">
|
||||
<el-scrollbar>
|
||||
<el-menu :default-active="route.path" router class="sub-menu">
|
||||
<template v-for="s in sideMenus" :key="s.id">
|
||||
<el-sub-menu v-if="s.children?.length" :index="String(s.id)">
|
||||
<template #title>
|
||||
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ s.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="c in s.children" :key="c.id" :index="c.path">
|
||||
<RuiIcon v-if="c.icon" :icon="c.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ c.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="s.path">
|
||||
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ s.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</div>
|
||||
</div>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-header { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.top-header-nav { height: 52px; background: #fff; padding: 0 20px; }
|
||||
html.dark .top-header-nav { background: #1d1d1d; }
|
||||
|
||||
/* 面包屑区域 */
|
||||
.top-header-bottom { background: #fff; border-top: 1px solid var(--el-border-color-light); }
|
||||
html.dark .top-header-bottom { background: #1d1d1d; border-color: #2a2a2a; }
|
||||
.breadcrumb-wrap {
|
||||
display: flex; align-items: center;
|
||||
height: 28px; padding: 0 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.breadcrumb-wrap .el-breadcrumb { line-height: 1; }
|
||||
|
||||
/* 二级侧边栏 */
|
||||
.side-sub { width: 180px; background: #fff; border-right: 1px solid #eee; flex-shrink: 0; }
|
||||
.sub-menu { border-right: none !important; }
|
||||
.sub-menu .el-menu-item.is-active {
|
||||
background: rgba(0,0,0,0.04);
|
||||
border-right: 3px solid var(--el-color-primary);
|
||||
color: var(--el-color-primary); font-weight: 500;
|
||||
}
|
||||
.menu-icon { margin-right: 5px; }
|
||||
|
||||
/* 主内容区 */
|
||||
.main { background: #f5f6fa; flex: 1; padding: 20px; overflow: auto; }
|
||||
|
||||
/* 暗色模式适配 */
|
||||
html.dark .side-sub { background: #1d1d1d; border-color: #333; }
|
||||
html.dark .main { background: #111; }
|
||||
html.dark .sub-menu .el-menu-item.is-active { background: rgba(255,255,255,0.06); }
|
||||
html.dark .el-breadcrumb__inner { color: #ccc; }
|
||||
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
|
||||
html.dark .el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useMenu } from '@/composables/useMenu'
|
||||
import TagsBar from '@/components/TagsBar.vue'
|
||||
import RuiIcon from '@/components/RuiIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const user = useUserStore()
|
||||
const { menuItems, loadMenus, getBreadcrumbs } = useMenu()
|
||||
const isCollapse = ref(false)
|
||||
|
||||
/**
|
||||
* 加载菜单
|
||||
*/
|
||||
async function loadMenuData() {
|
||||
try {
|
||||
await loadMenus('admin')
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 面包屑数据
|
||||
*/
|
||||
const breadcrumbs = computed(() => {
|
||||
const crumbs = getBreadcrumbs(route.path)
|
||||
if (crumbs.length === 0) {
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
|
||||
}
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadMenuData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="h-screen">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="side-bar">
|
||||
<div class="side-logo" :class="{ collapsed: isCollapse }">
|
||||
<el-icon :size="22"><Setting /></el-icon>
|
||||
<span v-show="!isCollapse" class="side-logo-text">{{ t('app.title') }}</span>
|
||||
</div>
|
||||
<el-scrollbar>
|
||||
<el-menu :default-active="route.path" :collapse="isCollapse" router class="side-menu" :collapse-transition="false">
|
||||
<template v-for="item in menuItems" :key="item.id">
|
||||
<!-- 有子菜单 -->
|
||||
<el-sub-menu v-if="item.children?.length" :index="String(item.id)">
|
||||
<template #title>
|
||||
<el-tooltip v-if="isCollapse" :content="item.title" placement="right">
|
||||
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
|
||||
</el-tooltip>
|
||||
<RuiIcon v-else-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<template v-for="child in item.children" :key="child.id">
|
||||
<!-- 子菜单还有子菜单(三级菜单) -->
|
||||
<el-sub-menu v-if="child.children?.length" :index="String(child.id)">
|
||||
<template #title>
|
||||
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ child.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="sub in child.children" :key="sub.id" :index="sub.path">
|
||||
<RuiIcon v-if="sub.icon" :icon="sub.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ sub.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<!-- 二级菜单 -->
|
||||
<el-menu-item v-else :index="child.path">
|
||||
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ child.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
<!-- 无子菜单 -->
|
||||
<el-menu-item v-else :index="item.path">
|
||||
<el-tooltip v-if="isCollapse" :content="item.title" placement="right">
|
||||
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
|
||||
</el-tooltip>
|
||||
<RuiIcon v-else-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-container class="flex-1">
|
||||
<!-- 顶部栏 -->
|
||||
<el-header class="top-bar">
|
||||
<div class="top-bar-inner">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-icon :size="20" class="cursor-pointer hover:text-blue-500 transition-colors" @click="isCollapse = !isCollapse">
|
||||
<Fold v-if="!isCollapse" /><Expand v-else />
|
||||
</el-icon>
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
|
||||
{{ crumb.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 暗黑模式切换 -->
|
||||
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
|
||||
<Moon v-if="!app.dark" /><Sunny v-else />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<!-- 主题配置 -->
|
||||
<el-tooltip content="主题配置" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
|
||||
</el-tooltip>
|
||||
<!-- 通知 -->
|
||||
<el-dropdown>
|
||||
<el-badge :value="3" :offset="[-2, 2]">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<!-- 用户信息 -->
|
||||
<el-dropdown>
|
||||
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
|
||||
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
|
||||
<span class="text-sm">{{ user.username }}</span>
|
||||
<el-icon :size="14"><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-icon><User /></el-icon>{{ t('common.personal') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="app.logout()">
|
||||
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<TagsBar />
|
||||
</el-header>
|
||||
<!-- 主内容 -->
|
||||
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 侧边栏暗色调 — 不受亮/暗模式影响 */
|
||||
.side-bar {
|
||||
background: #001529 !important;
|
||||
--el-menu-bg-color: #001529;
|
||||
--el-menu-text-color: #ffffffb3;
|
||||
--el-menu-hover-bg-color: #ffffff0d;
|
||||
--el-menu-active-color: #fff;
|
||||
--el-menu-border-color: transparent;
|
||||
--el-sub-menu-title-font-size: 14px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.side-logo {
|
||||
display: flex; align-items: center; gap: 10px; padding: 0 18px; height: 56px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06); color: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.side-logo.collapsed { justify-content: center; padding: 0; }
|
||||
.side-logo-text { font-size: 16px; font-weight: 700; white-space: nowrap; }
|
||||
.side-menu { border-right: none !important; }
|
||||
.menu-icon { margin-right: 5px; }
|
||||
/* 当前激活菜单项加背景色 */
|
||||
.side-menu .el-menu-item.is-active { background: rgba(255,255,255,0.08) !important; }
|
||||
.side-menu .el-sub-menu.is-active > .el-sub-menu__title { background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* 顶部栏 */
|
||||
.top-bar {
|
||||
padding: 0; height: auto;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
.top-bar-inner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 52px; background: #fff; padding: 0 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
html.dark .top-bar-inner { background: #1d1d1d; }
|
||||
|
||||
/* 主内容区 */
|
||||
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; transition: background 0.3s; }
|
||||
html.dark .main { background: #111; }
|
||||
|
||||
/* 面包屑样式优化 */
|
||||
.el-breadcrumb { font-size: 13px; }
|
||||
html.dark .el-breadcrumb__inner { color: #ccc; }
|
||||
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
|
||||
|
||||
/* 下拉菜单图标对齐 */
|
||||
.el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useMenu } from '@/composables/useMenu'
|
||||
import TagsBar from '@/components/TagsBar.vue'
|
||||
import RuiIcon from '@/components/RuiIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const user = useUserStore()
|
||||
const { menuItems, loadMenus, getBreadcrumbs } = useMenu()
|
||||
|
||||
onMounted(() => {
|
||||
loadMenus('admin')
|
||||
})
|
||||
|
||||
/**
|
||||
* 面包屑数据
|
||||
*/
|
||||
const breadcrumbs = computed(() => {
|
||||
const crumbs = getBreadcrumbs(route.path)
|
||||
if (crumbs.length === 0) {
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
|
||||
}
|
||||
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="h-screen" :class="{ dark: app.dark }">
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="top-header">
|
||||
<div class="top-header-nav">
|
||||
<div class="flex items-center gap-6 h-full">
|
||||
<!-- Logo -->
|
||||
<span class="text-lg font-bold whitespace-nowrap" :style="{ color: app.primaryColor }">{{ t('app.title') }}</span>
|
||||
<!-- 水平菜单 -->
|
||||
<el-menu mode="horizontal" :default-active="route.path" router class="top-menu">
|
||||
<template v-for="item in menuItems" :key="item.id">
|
||||
<!-- 有子菜单 -->
|
||||
<el-sub-menu v-if="item.children?.length" :index="String(item.id)">
|
||||
<template #title>
|
||||
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<template v-for="child in item.children" :key="child.id">
|
||||
<!-- 三级菜单 -->
|
||||
<el-sub-menu v-if="child.children?.length" :index="String(child.id)">
|
||||
<template #title>
|
||||
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ child.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="sub in child.children" :key="sub.id" :index="sub.path">{{ sub.title }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<!-- 二级菜单 -->
|
||||
<el-menu-item v-else :index="child.path">
|
||||
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ child.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
<!-- 无子菜单 -->
|
||||
<el-menu-item v-else :index="item.path">
|
||||
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
<!-- 右侧工具栏 -->
|
||||
<div class="flex items-center gap-3 ml-auto">
|
||||
<!-- 暗黑模式 -->
|
||||
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
|
||||
<Moon v-if="!app.dark" /><Sunny v-else />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<!-- 主题配置 -->
|
||||
<el-tooltip content="主题配置" placement="bottom">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
|
||||
</el-tooltip>
|
||||
<!-- 通知 -->
|
||||
<el-dropdown>
|
||||
<el-badge :value="3" :offset="[-2, 2]">
|
||||
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<!-- 用户 -->
|
||||
<el-dropdown>
|
||||
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
|
||||
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
|
||||
<span class="text-sm">{{ user.username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-icon><User /></el-icon>{{ t('common.personal') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="app.logout()">
|
||||
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 面包屑 + 标签栏 -->
|
||||
<div class="top-header-bottom">
|
||||
<div class="breadcrumb-wrap">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
|
||||
{{ crumb.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<TagsBar />
|
||||
</div>
|
||||
</el-header>
|
||||
<!-- 主内容 -->
|
||||
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-header { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.top-header-nav { height: 52px; background: #fff; padding: 0 20px; display: flex; align-items: center; }
|
||||
html.dark .top-header-nav { background: #1d1d1d; }
|
||||
|
||||
/* 面包屑区域 */
|
||||
.top-header-bottom { background: #fff; border-top: 1px solid var(--el-border-color-light); }
|
||||
html.dark .top-header-bottom { background: #1d1d1d; border-color: #2a2a2a; }
|
||||
.breadcrumb-wrap {
|
||||
display: flex; align-items: center;
|
||||
height: 28px; padding: 0 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.breadcrumb-wrap .el-breadcrumb { line-height: 1; }
|
||||
|
||||
.top-menu { border-bottom: none; height: 52px; flex: 1; }
|
||||
.menu-icon { margin-right: 5px; }
|
||||
|
||||
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; transition: background 0.3s; }
|
||||
html.dark .main { background: #111; }
|
||||
|
||||
/* 暗色模式下的菜单适配 */
|
||||
html.dark .top-menu {
|
||||
--el-menu-bg-color: #1d1d1d;
|
||||
--el-menu-text-color: #ccc;
|
||||
--el-menu-hover-text-color: var(--el-color-primary);
|
||||
--el-menu-active-color: var(--el-color-primary);
|
||||
}
|
||||
html.dark .el-menu--popup {
|
||||
--el-menu-bg-color: #2a2a2a;
|
||||
--el-menu-text-color: #ccc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
export default {
|
||||
app: {
|
||||
title: 'Rui Platform',
|
||||
titleShort: 'Rui',
|
||||
},
|
||||
menu: {
|
||||
home: 'Dashboard',
|
||||
user: 'User',
|
||||
userInfo: 'User Info',
|
||||
level: 'Level',
|
||||
levelList: 'Level List',
|
||||
levelLog: 'Level Log',
|
||||
address: 'Address',
|
||||
account: 'Account',
|
||||
system: 'System',
|
||||
systemAuth: 'Auth',
|
||||
systemOrg: 'Organization',
|
||||
systemMenu: 'Menu',
|
||||
systemRole: 'Role',
|
||||
systemDept: 'Department',
|
||||
systemDict: 'Dictionary',
|
||||
systemConfig: 'Config',
|
||||
systemLog: 'Log',
|
||||
order: 'Order',
|
||||
orderList: 'Order List',
|
||||
orderRefund: 'Refund',
|
||||
cms: 'Content',
|
||||
cmsArticle: 'Article',
|
||||
cmsCategory: 'Category',
|
||||
cmsBanner: 'Banner',
|
||||
marketing: 'Marketing',
|
||||
marketingCoupon: 'Coupon',
|
||||
marketingActivity: 'Activity',
|
||||
demo: 'Demo',
|
||||
demoIcons: 'Icons',
|
||||
demoList: 'List',
|
||||
settings: 'Settings',
|
||||
},
|
||||
common: {
|
||||
search: 'Search',
|
||||
reset: 'Reset',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
status: 'Status',
|
||||
operation: 'Operation',
|
||||
remark: 'Remark',
|
||||
personal: 'Profile',
|
||||
logout: 'Logout',
|
||||
fullscreen: 'Fullscreen',
|
||||
breadcrumb: { home: 'Home' },
|
||||
},
|
||||
theme: {
|
||||
title: 'Theme',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
layout: 'Layout',
|
||||
layoutSide: 'Sidebar',
|
||||
layoutTop: 'Top Menu',
|
||||
layoutMix: 'Mixed',
|
||||
layoutDouble: 'Double',
|
||||
color: 'Primary Color',
|
||||
fontSize: 'Font Size',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Overview',
|
||||
users: 'Total Users',
|
||||
today: 'New Today',
|
||||
active: 'Active Users',
|
||||
orders: 'Total Orders',
|
||||
recent: 'Recent Activity',
|
||||
vsLastWeek: 'vs last week',
|
||||
action: { register: 'registered', login: 'logged in', order: 'purchased', comment: 'commented' },
|
||||
},
|
||||
userInfo: {
|
||||
title: 'User Info',
|
||||
username: 'Username',
|
||||
nickname: 'Nickname',
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
add: 'Add User',
|
||||
edit: 'Edit User',
|
||||
deleteConfirm: 'Delete user "{name}"?',
|
||||
},
|
||||
userLevel: {
|
||||
title: 'Level Management',
|
||||
name: 'Level Name',
|
||||
code: 'Code',
|
||||
minScore: 'Min Score',
|
||||
maxScore: 'Max Score',
|
||||
add: 'Add Level',
|
||||
edit: 'Edit Level',
|
||||
deleteConfirm: 'Delete "{name}"?',
|
||||
},
|
||||
context: {
|
||||
close: 'Close',
|
||||
refresh: 'Refresh',
|
||||
fullscreen: 'Fullscreen',
|
||||
closeOther: 'Close Others',
|
||||
closeAll: 'Close All',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './zh-CN'
|
||||
import enUS from './en-US'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: localStorage.getItem('lang') || 'zh-CN',
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages: { 'zh-CN': zhCN, 'en-US': enUS },
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,121 @@
|
||||
export default {
|
||||
app: {
|
||||
title: '睿核通用平台',
|
||||
titleShort: '睿核',
|
||||
},
|
||||
menu: {
|
||||
home: '首页',
|
||||
user: '用户管理',
|
||||
userInfo: '用户信息',
|
||||
userDetail: '用户详情',
|
||||
level: '等级管理',
|
||||
levelList: '等级列表',
|
||||
levelLog: '等级日志',
|
||||
address: '收货地址',
|
||||
account: '账户流水',
|
||||
system: '系统管理',
|
||||
systemAuth: '权限管理',
|
||||
systemOrg: '组织架构',
|
||||
systemMenu: '菜单管理',
|
||||
systemRole: '角色管理',
|
||||
systemDept: '部门管理',
|
||||
systemDict: '字典管理',
|
||||
systemConfig: '参数配置',
|
||||
systemLog: '操作日志',
|
||||
systemLoginLog: '登录日志',
|
||||
systemTenant: '租户管理',
|
||||
systemOAuth2Client: 'OAuth2客户端',
|
||||
order: '订单管理',
|
||||
orderList: '订单列表',
|
||||
orderRefund: '退款记录',
|
||||
cms: '内容管理',
|
||||
cmsArticle: '文章管理',
|
||||
cmsCategory: '分类管理',
|
||||
cmsBanner: '轮播图',
|
||||
marketing: '营销中心',
|
||||
marketingCoupon: '优惠券',
|
||||
marketingActivity: '活动管理',
|
||||
demo: '演示中心',
|
||||
demoIcons: '图标演示',
|
||||
demoList: '列表演示',
|
||||
settings: '系统设置',
|
||||
},
|
||||
common: {
|
||||
search: '查询',
|
||||
reset: '重置',
|
||||
add: '新增',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
confirm: '确定',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
status: '状态',
|
||||
operation: '操作',
|
||||
remark: '备注',
|
||||
personal: '个人中心',
|
||||
logout: '退出登录',
|
||||
fullscreen: '全屏',
|
||||
all: '全部',
|
||||
query: '查询',
|
||||
batchDelete: '批量删除',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
breadcrumb: { home: '首页' },
|
||||
},
|
||||
theme: {
|
||||
title: '主题配置',
|
||||
dark: '暗黑模式',
|
||||
light: '明亮模式',
|
||||
layout: '布局风格',
|
||||
layoutSide: '侧边栏',
|
||||
layoutTop: '顶栏',
|
||||
layoutMix: '混合',
|
||||
layoutDouble: '双栏',
|
||||
color: '主题色',
|
||||
fontSize: '字号',
|
||||
},
|
||||
dashboard: {
|
||||
title: '数据概览',
|
||||
users: '用户总数',
|
||||
today: '今日新增',
|
||||
active: '活跃用户',
|
||||
orders: '订单总数',
|
||||
recent: '最近动态',
|
||||
vsLastWeek: '较上周',
|
||||
action: { register: '注册', login: '登录', order: '购买', comment: '评论' },
|
||||
},
|
||||
userInfo: {
|
||||
title: '用户信息',
|
||||
username: '用户名',
|
||||
nickname: '昵称',
|
||||
phone: '手机号',
|
||||
email: '邮箱',
|
||||
status: '状态',
|
||||
userType: '用户类型',
|
||||
createdAt: '创建时间',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
add: '新增用户',
|
||||
edit: '编辑用户',
|
||||
deleteConfirm: '确定删除用户「{name}」?',
|
||||
},
|
||||
userLevel: {
|
||||
title: '等级管理',
|
||||
name: '等级名称',
|
||||
code: '编码',
|
||||
minScore: '最低分值',
|
||||
maxScore: '最高分值',
|
||||
add: '新增等级',
|
||||
edit: '编辑等级',
|
||||
deleteConfirm: '确定删除「{name}」?',
|
||||
},
|
||||
context: {
|
||||
close: '关闭当前',
|
||||
refresh: '刷新当前',
|
||||
fullscreen: '全屏当前页',
|
||||
closeOther: '关闭其它',
|
||||
closeAll: '关闭所有',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './locales'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'uno.css'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue))
|
||||
app.component(key, component)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/**
|
||||
* 全局路由守卫
|
||||
*/
|
||||
export function setupRouterGuards(router: any) {
|
||||
// 白名单路由(无需登录)
|
||||
const whiteList = ['/login']
|
||||
|
||||
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 白名单直接放行
|
||||
if (whiteList.includes(to.path)) {
|
||||
// 已登录用户访问登录页,重定向到首页
|
||||
if (authStore.isLoggedIn && to.path === '/login') {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 需要登录的页面
|
||||
if (!authStore.isLoggedIn) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import routes from 'virtual:generated-routes'
|
||||
import { setupRouterGuards } from './guards'
|
||||
|
||||
const router = createRouter({ history: createWebHashHistory(), routes })
|
||||
|
||||
// 设置路由守卫
|
||||
setupRouterGuards(router)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
cashierStore: 'menu.cashierStore',
|
||||
cashierRoom: 'menu.cashierRoom',
|
||||
cashierPricing: 'menu.cashierPricing',
|
||||
cashierOrder: 'menu.cashierOrder',
|
||||
cashierProduct: 'menu.cashierProduct',
|
||||
cashierReport: 'menu.cashierReport',
|
||||
}
|
||||
|
||||
export const cashierRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'cashier/store', name: 'CashierStore', component: () => import('@/views/cashier/store/Index.vue'), meta: { i18n: M.cashierStore } },
|
||||
{ path: 'cashier/room', name: 'CashierRoom', component: () => import('@/views/cashier/room/Index.vue'), meta: { i18n: M.cashierRoom } },
|
||||
{ path: 'cashier/pricing', name: 'CashierPricing', component: () => import('@/views/cashier/pricing/Index.vue'), meta: { i18n: M.cashierPricing } },
|
||||
{ path: 'cashier/order', name: 'CashierOrder', component: () => import('@/views/cashier/order/Index.vue'), meta: { i18n: M.cashierOrder } },
|
||||
{ path: 'cashier/product', name: 'CashierProduct', component: () => import('@/views/cashier/product/Index.vue'), meta: { i18n: M.cashierProduct } },
|
||||
{ path: 'cashier/report', name: 'CashierReport', component: () => import('@/views/cashier/report/Index.vue'), meta: { i18n: M.cashierReport } },
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
cmsArticle: 'menu.cmsArticle',
|
||||
cmsCategory: 'menu.cmsCategory',
|
||||
cmsBanner: 'menu.cmsBanner',
|
||||
}
|
||||
|
||||
export const cmsRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'cms/article', name: 'CmsArticle', component: () => import('@/views/cms/article/Index.vue'), meta: { i18n: M.cmsArticle } },
|
||||
{ path: 'cms/category', name: 'CmsCategory', component: () => import('@/views/cms/category/Index.vue'), meta: { i18n: M.cmsCategory } },
|
||||
{ path: 'cms/banner', name: 'CmsBanner', component: () => import('@/views/cms/banner/Index.vue'), meta: { i18n: M.cmsBanner } },
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 核心路由 - 所有系统默认包含
|
||||
*/
|
||||
export const coreRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/Index.vue'),
|
||||
meta: { hidden: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/DefaultLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/Index.vue'),
|
||||
meta: { i18n: 'menu.home' },
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/profile/Index.vue'),
|
||||
meta: { i18n: 'common.personal', hidden: true },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/settings/Index.vue'),
|
||||
meta: { i18n: 'menu.settings' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
demoIcons: 'menu.demoIcons',
|
||||
demoList: 'menu.demoList',
|
||||
}
|
||||
|
||||
export const demoRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'demo/icons', name: 'DemoIcons', component: () => import('@/views/demo/Icons.vue'), meta: { i18n: M.demoIcons } },
|
||||
{ path: 'demo/list', name: 'DemoList', component: () => import('@/views/demo/list/Index.vue'), meta: { i18n: M.demoList } },
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
coupon: 'menu.marketingCoupon',
|
||||
activity: 'menu.marketingActivity',
|
||||
}
|
||||
|
||||
export const marketingRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'marketing/coupon', name: 'MarketingCoupon', component: () => import('@/views/marketing/coupon/Index.vue'), meta: { i18n: M.coupon } },
|
||||
{ path: 'marketing/activity', name: 'MarketingActivity', component: () => import('@/views/marketing/activity/Index.vue'), meta: { i18n: M.activity } },
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
orderList: 'menu.orderList',
|
||||
orderRefund: 'menu.orderRefund',
|
||||
}
|
||||
|
||||
export const orderRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'order/list', name: 'OrderList', component: () => import('@/views/order/list/Index.vue'), meta: { i18n: M.orderList } },
|
||||
{ path: 'order/refund', name: 'OrderRefund', component: () => import('@/views/order/refund/Index.vue'), meta: { i18n: M.orderRefund } },
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
systemMenu: 'menu.systemMenu',
|
||||
systemRole: 'menu.systemRole',
|
||||
systemDept: 'menu.systemDept',
|
||||
systemPost: 'menu.systemPost',
|
||||
systemDict: 'menu.systemDict',
|
||||
systemConfig: 'menu.systemConfig',
|
||||
systemLog: 'menu.systemLog',
|
||||
systemLoginLog: 'menu.systemLoginLog',
|
||||
systemTenant: 'menu.systemTenant',
|
||||
systemTenantPackage: 'menu.systemTenantPackage',
|
||||
systemDataScope: 'menu.systemDataScope',
|
||||
systemOAuth2Client: 'menu.systemOAuth2Client',
|
||||
}
|
||||
|
||||
export const systemRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'system/menu', name: 'SystemMenu', component: () => import('@/views/system/menu/Index.vue'), meta: { i18n: M.systemMenu } },
|
||||
{ path: 'system/role', name: 'SystemRole', component: () => import('@/views/system/role/Index.vue'), meta: { i18n: M.systemRole } },
|
||||
{ path: 'system/dept', name: 'SystemDept', component: () => import('@/views/system/dept/Index.vue'), meta: { i18n: M.systemDept } },
|
||||
{ path: 'system/post', name: 'SystemPost', component: () => import('@/views/system/post/Index.vue'), meta: { i18n: M.systemPost } },
|
||||
{ path: 'system/dict', name: 'SystemDict', component: () => import('@/views/system/dict/Index.vue'), meta: { i18n: M.systemDict } },
|
||||
{ path: 'system/config', name: 'SystemConfig', component: () => import('@/views/system/config/Index.vue'), meta: { i18n: M.systemConfig } },
|
||||
{ path: 'system/log', name: 'SystemLog', component: () => import('@/views/system/log/Index.vue'), meta: { i18n: M.systemLog } },
|
||||
{ path: 'system/login-log', name: 'SystemLoginLog', component: () => import('@/views/system/login-log/Index.vue'), meta: { i18n: M.systemLoginLog } },
|
||||
{ path: 'system/tenant', name: 'SystemTenant', component: () => import('@/views/system/tenant/Index.vue'), meta: { i18n: M.systemTenant } },
|
||||
{ path: 'system/tenant-package', name: 'SystemTenantPackage', component: () => import('@/views/system/tenant-package/Index.vue'), meta: { i18n: M.systemTenantPackage } },
|
||||
{ path: 'system/data-scope', name: 'SystemDataScope', component: () => import('@/views/system/data-scope/Index.vue'), meta: { i18n: M.systemDataScope } },
|
||||
{ path: 'system/oauth2-client', name: 'SystemOAuth2Client', component: () => import('@/views/system/oauth2-client/Index.vue'), meta: { i18n: M.systemOAuth2Client } },
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const M = {
|
||||
userInfo: 'menu.userInfo',
|
||||
userDetail: 'menu.userDetail',
|
||||
level: 'menu.levelList',
|
||||
levelLog: 'menu.levelLog',
|
||||
address: 'menu.address',
|
||||
account: 'menu.account',
|
||||
}
|
||||
|
||||
export const userRoutes: RouteRecordRaw[] = [
|
||||
{ path: 'user/info', name: 'UserInfo', component: () => import('@/views/user/info/Index.vue'), meta: { i18n: M.userInfo } },
|
||||
{ path: 'user/detail', name: 'UserDetail', component: () => import('@/views/user/detail/Index.vue'), meta: { i18n: M.userDetail } },
|
||||
{ path: 'user/level', name: 'UserLevel', component: () => import('@/views/user/level/Index.vue'), meta: { i18n: M.level } },
|
||||
{ path: 'user/level-log', name: 'UserLevelLog', component: () => import('@/views/user/level-log/Index.vue'), meta: { i18n: M.levelLog } },
|
||||
{ path: 'user/address', name: 'UserAddress', component: () => import('@/views/user/address/Index.vue'), meta: { i18n: M.address } },
|
||||
{ path: 'user/account', name: 'UserAccount', component: () => import('@/views/user/account/Index.vue'), meta: { i18n: M.account } },
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 分页查询结果
|
||||
*/
|
||||
export interface PageResult<T = any> {
|
||||
/** 列表数据 */
|
||||
list: T[]
|
||||
/** 总记录数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页参数
|
||||
*/
|
||||
export interface PageParams {
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Service 基类
|
||||
*
|
||||
* <p>封装标准 CRUD 操作,子类只需传入 baseUrl 即可使用:</p>
|
||||
*
|
||||
* <pre>
|
||||
* class UserService extends BaseService {
|
||||
* constructor() {
|
||||
* super('/user/admin/user')
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
export class BaseService<T = any> {
|
||||
/** 接口基础路径 */
|
||||
protected baseUrl: string
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询
|
||||
*
|
||||
* @param params 分页参数 + 查询条件
|
||||
* @returns 分页结果
|
||||
*/
|
||||
async page(params: PageParams & Record<string, any>): Promise<PageResult<T>> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/page`,
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
return {
|
||||
list: res.data?.records || [],
|
||||
total: res.data?.total || 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表查询
|
||||
*
|
||||
* @param params 查询条件
|
||||
* @returns 数据列表
|
||||
*/
|
||||
async list(params?: Record<string, any>): Promise<T[]> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/list`,
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询详情
|
||||
*
|
||||
* @param id 主键 ID
|
||||
* @returns 实体数据
|
||||
*/
|
||||
async getById(id: number | string): Promise<T> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*
|
||||
* @param data 实体数据
|
||||
* @returns 新增后的实体(包含生成的ID)
|
||||
*/
|
||||
async add(data: Partial<T>): Promise<T> {
|
||||
const res: any = await request({
|
||||
url: this.baseUrl,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改
|
||||
*
|
||||
* @param data 实体数据(必须包含 id)
|
||||
* @returns 是否成功
|
||||
*/
|
||||
async update(data: Partial<T> & { id: number | string }): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: this.baseUrl,
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*
|
||||
* @param id 主键 ID
|
||||
* @returns 是否成功
|
||||
*/
|
||||
async remove(id: number | string): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除(调用后端批量删除接口)
|
||||
*
|
||||
* @param ids 主键 ID 列表
|
||||
* @returns 是否成功
|
||||
*/
|
||||
async batchRemove(ids: (number | string)[]): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/batch`,
|
||||
method: 'delete',
|
||||
data: ids,
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态切换(启用/禁用)
|
||||
*
|
||||
* @param id 主键 ID
|
||||
* @param status 状态值(0禁用 1启用)
|
||||
* @returns 是否成功
|
||||
*/
|
||||
async changeStatus(id: number | string, status: number): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/status`,
|
||||
method: 'put',
|
||||
params: { id, status },
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* OAuth2 Token 响应
|
||||
*/
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
refresh_token?: string
|
||||
scope?: string
|
||||
tenantId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录参数
|
||||
*/
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
tenantId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
class AuthService {
|
||||
/**
|
||||
* OAuth2 密码模式登录
|
||||
*/
|
||||
async login(params: LoginParams): Promise<TokenResponse> {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append('grant_type', 'password')
|
||||
formData.append('username', params.username)
|
||||
formData.append('password', params.password)
|
||||
if (params.tenantId) {
|
||||
formData.append('tenantId', params.tenantId)
|
||||
}
|
||||
|
||||
const res: any = await request({
|
||||
url: '/oauth2/token',
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: 'Basic ' + import.meta.env.VITE_OAUTH2_CLIENT_SECRET,
|
||||
},
|
||||
data: formData,
|
||||
})
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append('grant_type', 'refresh_token')
|
||||
formData.append('refresh_token', refreshToken)
|
||||
|
||||
const res: any = await request({
|
||||
url: '/oauth2/token',
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: 'Basic ' + import.meta.env.VITE_OAUTH2_CLIENT_SECRET,
|
||||
},
|
||||
data: formData,
|
||||
})
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
*/
|
||||
async getUserInfo(): Promise<any> {
|
||||
const res: any = await request({
|
||||
url: '/user/admin/user/current',
|
||||
method: 'get',
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request({
|
||||
url: '/oauth2/revoke',
|
||||
method: 'post',
|
||||
})
|
||||
} catch {
|
||||
// 即使后端登出失败,前端也要清理本地状态
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService()
|
||||
@@ -0,0 +1,68 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 订单服务
|
||||
*/
|
||||
class OrderService extends BaseService {
|
||||
constructor() {
|
||||
super('/cashier/admin/order')
|
||||
}
|
||||
|
||||
/**
|
||||
* 开台
|
||||
*/
|
||||
async openRoom(data: {
|
||||
storeId: number
|
||||
roomId: number
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
orderType?: number
|
||||
remark?: string
|
||||
}): Promise<any> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/open`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 结账
|
||||
*/
|
||||
async checkout(id: number): Promise<any> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/${id}/checkout`,
|
||||
method: 'post',
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付
|
||||
*/
|
||||
async pay(id: number, data: any): Promise<any> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/${id}/pay`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款
|
||||
*/
|
||||
async refund(id: number, data: { amount: number; reason: string }): Promise<any> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/${id}/refund`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
}
|
||||
|
||||
/** 订单服务单例 */
|
||||
export const orderService = new OrderService()
|
||||
@@ -0,0 +1,61 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 定价策略服务
|
||||
*/
|
||||
class PricingService extends BaseService {
|
||||
constructor() {
|
||||
super('/cashier/admin/pricing-strategy')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询策略下的套餐列表
|
||||
*/
|
||||
async getPackages(strategyId: number): Promise<any[]> {
|
||||
const res: any = await request({
|
||||
url: `/cashier/admin/pricing-package/list`,
|
||||
method: 'get',
|
||||
params: { strategyId },
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增套餐
|
||||
*/
|
||||
async addPackage(data: any) {
|
||||
const res: any = await request({
|
||||
url: '/cashier/admin/pricing-package',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改套餐
|
||||
*/
|
||||
async updatePackage(data: any) {
|
||||
const res: any = await request({
|
||||
url: '/cashier/admin/pricing-package',
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除套餐
|
||||
*/
|
||||
async deletePackage(id: number) {
|
||||
const res: any = await request({
|
||||
url: `/cashier/admin/pricing-package/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
}
|
||||
|
||||
/** 定价策略服务单例 */
|
||||
export const pricingService = new PricingService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 商品服务
|
||||
*/
|
||||
class ProductService extends BaseService {
|
||||
constructor() {
|
||||
super('/cashier/admin/product')
|
||||
}
|
||||
}
|
||||
|
||||
/** 商品服务单例 */
|
||||
export const productService = new ProductService()
|
||||
@@ -0,0 +1,33 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 报表服务
|
||||
*/
|
||||
class ReportService {
|
||||
/**
|
||||
* 营业日报
|
||||
*/
|
||||
async getDailyReport(storeId: number, date: string): Promise<any> {
|
||||
const res: any = await request({
|
||||
url: '/cashier/admin/report/daily',
|
||||
method: 'get',
|
||||
params: { storeId, date },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 包间利用率
|
||||
*/
|
||||
async getRoomUsage(storeId: number, date: string): Promise<any[]> {
|
||||
const res: any = await request({
|
||||
url: '/cashier/admin/report/room-usage',
|
||||
method: 'get',
|
||||
params: { storeId, date },
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
}
|
||||
|
||||
/** 报表服务单例 */
|
||||
export const reportService = new ReportService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 包间服务
|
||||
*/
|
||||
class RoomService extends BaseService {
|
||||
constructor() {
|
||||
super('/cashier/admin/room')
|
||||
}
|
||||
}
|
||||
|
||||
/** 包间服务单例 */
|
||||
export const roomService = new RoomService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 门店服务
|
||||
*/
|
||||
class StoreService extends BaseService {
|
||||
constructor() {
|
||||
super('/cashier/admin/store')
|
||||
}
|
||||
}
|
||||
|
||||
/** 门店服务单例 */
|
||||
export const storeService = new StoreService()
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 参数配置 Service
|
||||
*/
|
||||
class ConfigService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/config')
|
||||
}
|
||||
}
|
||||
|
||||
export const configService = new ConfigService()
|
||||
@@ -0,0 +1,36 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 数据权限服务
|
||||
*/
|
||||
class DataScopeService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/data-scope')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询角色已分配的部门ID列表
|
||||
*/
|
||||
async listDeptIdsByRoleId(roleId: number | string): Promise<number[]> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/role/${roleId}`,
|
||||
method: 'get',
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配角色数据权限(自定义部门范围)
|
||||
*/
|
||||
async assignDataScope(roleId: number | string, deptIds: number[]): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/role/${roleId}`,
|
||||
method: 'post',
|
||||
data: deptIds,
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
}
|
||||
|
||||
export const dataScopeService = new DataScopeService()
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 部门服务
|
||||
*/
|
||||
class DeptService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/dept')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询部门树
|
||||
*/
|
||||
async tree(): Promise<any[]> {
|
||||
const res: any = await request({
|
||||
url: '/system/admin/dept/list',
|
||||
method: 'get',
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
}
|
||||
|
||||
/** 部门服务单例 */
|
||||
export const deptService = new DeptService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 字典项服务
|
||||
*/
|
||||
class DictItemService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/dict/item')
|
||||
}
|
||||
}
|
||||
|
||||
/** 字典项服务单例 */
|
||||
export const dictItemService = new DictItemService()
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 字典类型 Service
|
||||
*/
|
||||
class DictService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/dict/type')
|
||||
}
|
||||
}
|
||||
|
||||
export const dictService = new DictService()
|
||||
@@ -0,0 +1,8 @@
|
||||
export { menuService } from './menuService'
|
||||
export { tenantService } from './tenantService'
|
||||
export { roleService } from './roleService'
|
||||
export { deptService } from './deptService'
|
||||
export { dictService } from './dictService'
|
||||
export { dictItemService } from './dictItemService'
|
||||
export { configService } from './configService'
|
||||
export { oauth2ClientService } from './oauth2ClientService'
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 登录日志服务
|
||||
*/
|
||||
class LoginLogService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/login-log')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
async clear(): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: '/system/admin/login-log/clear',
|
||||
method: 'delete',
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录日志服务单例 */
|
||||
export const loginLogService = new LoginLogService()
|
||||
@@ -0,0 +1,40 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 菜单服务
|
||||
*/
|
||||
class MenuService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/menu')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询菜单树(菜单管理用,返回全部菜单)
|
||||
* @param platform 所属平台 admin|app
|
||||
*/
|
||||
async tree(platform?: string): Promise<any[]> {
|
||||
const res: any = await request({
|
||||
url: '/system/admin/menu/tree',
|
||||
method: 'get',
|
||||
params: platform ? { platform } : undefined,
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户菜单树(框架侧边栏用,按角色权限过滤)
|
||||
* @param platform 所属平台 admin|app
|
||||
*/
|
||||
async userTree(platform?: string): Promise<any[]> {
|
||||
const res: any = await request({
|
||||
url: '/system/admin/menu/user-tree',
|
||||
method: 'get',
|
||||
params: platform ? { platform } : undefined,
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
}
|
||||
|
||||
/** 菜单服务单例 */
|
||||
export const menuService = new MenuService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* OAuth2 客户端服务
|
||||
*/
|
||||
class OAuth2ClientService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/oauth2-client')
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth2 客户端服务单例 */
|
||||
export const oauth2ClientService = new OAuth2ClientService()
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 操作日志服务
|
||||
*/
|
||||
class OperLogService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/log')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
async clear(): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: '/system/admin/log/clear',
|
||||
method: 'delete',
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
}
|
||||
|
||||
/** 操作日志服务单例 */
|
||||
export const operLogService = new OperLogService()
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 岗位服务
|
||||
*/
|
||||
class PostService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/post')
|
||||
}
|
||||
}
|
||||
|
||||
export const postService = new PostService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 角色服务
|
||||
*/
|
||||
class RoleService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/role')
|
||||
}
|
||||
}
|
||||
|
||||
/** 角色服务单例 */
|
||||
export const roleService = new RoleService()
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 租户套餐服务
|
||||
*/
|
||||
class TenantPackageService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/tenant-package')
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantPackageService = new TenantPackageService()
|
||||
@@ -0,0 +1,60 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 租户服务
|
||||
*/
|
||||
class TenantService extends BaseService {
|
||||
constructor() {
|
||||
super('/system/admin/tenant')
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化租户
|
||||
*/
|
||||
async initTenant(tenantId: number | string): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `/system/admin/tenant/${tenantId}/init`,
|
||||
method: 'post',
|
||||
})
|
||||
return res.error === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改租户管理员密码
|
||||
*/
|
||||
async updateAdminPassword(tenantId: number | string, newPassword: string): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `/system/admin/tenant/${tenantId}/admin/password`,
|
||||
method: 'post',
|
||||
data: { newPassword },
|
||||
})
|
||||
return res.error === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户已启用模块
|
||||
*/
|
||||
async getModules(tenantId: number | string): Promise<string> {
|
||||
const res: any = await request({
|
||||
url: `/system/admin/tenant/${tenantId}/modules`,
|
||||
method: 'get',
|
||||
})
|
||||
return res.data || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步租户模块
|
||||
*/
|
||||
async syncModules(tenantId: number | string, modules: string): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `/system/admin/tenant/${tenantId}/modules`,
|
||||
method: 'post',
|
||||
data: { modules },
|
||||
})
|
||||
return res.error === 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 租户服务单例 */
|
||||
export const tenantService = new TenantService()
|
||||
@@ -0,0 +1,6 @@
|
||||
export { userService } from './userService'
|
||||
export { userDetailService } from './userDetailService'
|
||||
export { userDeptService } from './userDeptService'
|
||||
export { userPostService } from './userPostService'
|
||||
export { levelService } from './levelService'
|
||||
export { levelLogService } from './levelLogService'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 用户等级日志服务
|
||||
*/
|
||||
class LevelLogService extends BaseService {
|
||||
constructor() {
|
||||
super('/user/admin/level-log')
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户等级日志服务单例 */
|
||||
export const levelLogService = new LevelLogService()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 用户等级服务
|
||||
*/
|
||||
class LevelService extends BaseService {
|
||||
constructor() {
|
||||
super('/user/admin/level')
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户等级服务单例 */
|
||||
export const levelService = new LevelService()
|
||||
@@ -0,0 +1,34 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 用户部门关联服务
|
||||
*/
|
||||
class UserDeptService {
|
||||
private baseUrl = '/user/admin/user-dept'
|
||||
|
||||
/**
|
||||
* 查询用户已分配的部门ID列表
|
||||
*/
|
||||
async listDeptIdsByUserId(userId: number | string): Promise<number[]> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/user/${userId}`,
|
||||
method: 'get',
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配用户部门
|
||||
*/
|
||||
async assignDepts(userId: number | string, deptIds: number[]): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/user/${userId}`,
|
||||
method: 'post',
|
||||
data: deptIds,
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户部门关联服务单例 */
|
||||
export const userDeptService = new UserDeptService()
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseService } from '@/service/BaseService'
|
||||
|
||||
/**
|
||||
* 用户详情服务
|
||||
*/
|
||||
class UserDetailService extends BaseService {
|
||||
constructor() {
|
||||
super('/user/admin/detail')
|
||||
}
|
||||
}
|
||||
|
||||
export const userDetailService = new UserDetailService()
|
||||
@@ -0,0 +1,34 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 用户岗位关联服务
|
||||
*/
|
||||
class UserPostService {
|
||||
private baseUrl = '/user/admin/user-post'
|
||||
|
||||
/**
|
||||
* 查询用户已分配的岗位ID列表
|
||||
*/
|
||||
async listPostIdsByUserId(userId: number | string): Promise<number[]> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/user/${userId}`,
|
||||
method: 'get',
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配用户岗位
|
||||
*/
|
||||
async assignPosts(userId: number | string, postIds: number[]): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `${this.baseUrl}/user/${userId}`,
|
||||
method: 'post',
|
||||
data: postIds,
|
||||
})
|
||||
return res.data === true
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户岗位关联服务单例 */
|
||||
export const userPostService = new UserPostService()
|
||||
@@ -0,0 +1,39 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { BaseService } from '../BaseService'
|
||||
|
||||
/**
|
||||
* 用户服务
|
||||
*
|
||||
* <p>封装用户相关 API 调用,继承 BaseService 获得标准 CRUD 能力</p>
|
||||
*/
|
||||
class UserService extends BaseService {
|
||||
constructor() {
|
||||
super('/user/admin/user')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户已分配的角色列表
|
||||
*/
|
||||
async getRoles(userId: number | string): Promise<number[]> {
|
||||
const res: any = await request({
|
||||
url: `/user/admin/user/${userId}/roles`,
|
||||
method: 'get',
|
||||
})
|
||||
return res.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配角色
|
||||
*/
|
||||
async assignRoles(userId: number | string, roleIds: number[]): Promise<boolean> {
|
||||
const res: any = await request({
|
||||
url: `/user/admin/user/${userId}/roles`,
|
||||
method: 'post',
|
||||
data: roleIds,
|
||||
})
|
||||
return res.error === 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户服务单例 */
|
||||
export const userService = new UserService()
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const dark = ref(localStorage.getItem('dark') === 'true')
|
||||
const lang = ref(localStorage.getItem('lang') || 'zh-CN')
|
||||
const layout = ref(localStorage.getItem('layout') || 'double')
|
||||
const primaryColor = ref(localStorage.getItem('primaryColor') || '#1677ff')
|
||||
const themeVisible = ref(false)
|
||||
const pageFullscreen = ref(false)
|
||||
|
||||
function toggleDark() { dark.value = !dark.value; localStorage.setItem('dark', String(dark.value)); applyDark() }
|
||||
function setLang(l: string) { lang.value = l; localStorage.setItem('lang', l) }
|
||||
function setLayout(l: string) { layout.value = l; localStorage.setItem('layout', l) }
|
||||
function setPrimaryColor(c: string) { primaryColor.value = c; localStorage.setItem('primaryColor', c); document.documentElement.style.setProperty('--el-color-primary', c) }
|
||||
function togglePageFullscreen() { pageFullscreen.value = !pageFullscreen.value }
|
||||
function logout() { localStorage.removeItem('token'); window.location.hash = '#/login' }
|
||||
function applyDark() { dark.value ? document.documentElement.classList.add('dark') : document.documentElement.classList.remove('dark') }
|
||||
|
||||
applyDark()
|
||||
document.documentElement.style.setProperty('--el-color-primary', primaryColor.value)
|
||||
|
||||
return { dark, lang, layout, primaryColor, themeVisible, pageFullscreen, toggleDark, setLang, setLayout, setPrimaryColor, togglePageFullscreen, logout, applyDark }
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authService } from '@/service/authService'
|
||||
import type { TokenResponse, LoginParams } from '@/service/authService'
|
||||
import router from '@/router'
|
||||
|
||||
export interface UserInfo {
|
||||
userId?: number
|
||||
username?: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
tenantId?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ============ State ============
|
||||
const token = ref<TokenResponse | null>(null)
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const isLoggedIn = computed(() => !!token.value?.access_token)
|
||||
|
||||
// ============ Getters ============
|
||||
const accessToken = computed(() => token.value?.access_token || '')
|
||||
const username = computed(() => userInfo.value?.nickname || userInfo.value?.username || '')
|
||||
const avatar = computed(() => userInfo.value?.avatar || '')
|
||||
const tenantId = computed(() => userInfo.value?.tenantId || token.value?.tenantId || 0)
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载认证状态
|
||||
*/
|
||||
function loadAuth() {
|
||||
try {
|
||||
const tokenStr = localStorage.getItem('token')
|
||||
if (tokenStr) {
|
||||
token.value = JSON.parse(tokenStr)
|
||||
}
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
userInfo.value = JSON.parse(userStr)
|
||||
}
|
||||
} catch {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存认证状态到 localStorage
|
||||
*/
|
||||
function saveAuth(tokenData: TokenResponse, user?: UserInfo) {
|
||||
token.value = tokenData
|
||||
localStorage.setItem('token', JSON.stringify(tokenData))
|
||||
if (user) {
|
||||
userInfo.value = user
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证状态
|
||||
*/
|
||||
function clearAuth() {
|
||||
token.value = null
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('tenantId')
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
async function login(params: LoginParams): Promise<boolean> {
|
||||
try {
|
||||
const tokenData = await authService.login(params)
|
||||
saveAuth(tokenData)
|
||||
|
||||
// 如果有租户ID,保存到 localStorage
|
||||
if (params.tenantId) {
|
||||
localStorage.setItem('tenantId', params.tenantId)
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
await fetchUserInfo()
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async function fetchUserInfo(): Promise<boolean> {
|
||||
try {
|
||||
const user = await authService.getUserInfo()
|
||||
userInfo.value = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
nickname: user.nickName || user.nickname || user.username,
|
||||
avatar: user.avatar,
|
||||
tenantId: user.tenantId,
|
||||
...user,
|
||||
}
|
||||
localStorage.setItem('user', JSON.stringify(userInfo.value))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
await authService.logout()
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
const refreshToken = token.value?.refresh_token
|
||||
if (!refreshToken) return false
|
||||
|
||||
try {
|
||||
const tokenData = await authService.refreshToken(refreshToken)
|
||||
saveAuth(tokenData)
|
||||
return true
|
||||
} catch {
|
||||
// 刷新失败,需要重新登录
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 初始化加载 ============
|
||||
loadAuth()
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
accessToken,
|
||||
username,
|
||||
avatar,
|
||||
tenantId,
|
||||
login,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
refreshAccessToken,
|
||||
clearAuth,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export interface TagItem { path: string; title: string }
|
||||
|
||||
export const useTagsStore = defineStore('tags', () => {
|
||||
const visited = ref<TagItem[]>(load())
|
||||
|
||||
function load() {
|
||||
const saved = JSON.parse(localStorage.getItem('tags') || '[]') as TagItem[]
|
||||
const hasHome = saved.some(t => t.path === '/dashboard')
|
||||
if (!hasHome) saved.unshift({ path: '/dashboard', title: 'menu.home' })
|
||||
return saved
|
||||
}
|
||||
|
||||
function addTag(route: RouteLocationNormalized) {
|
||||
if (route.meta.hidden) return
|
||||
const title = (route.meta.i18n as string) || route.name as string || ''
|
||||
if (!title) return
|
||||
const existIdx = visited.value.findIndex(t => t.path === route.path)
|
||||
if (existIdx >= 0) return
|
||||
visited.value.push({ path: route.path, title })
|
||||
save()
|
||||
}
|
||||
|
||||
function removeTag(path: string) {
|
||||
visited.value = visited.value.filter(t => t.path !== path)
|
||||
save()
|
||||
}
|
||||
|
||||
function closeOther(path: string) {
|
||||
visited.value = visited.value.filter(t => t.path === path || t.path === '/dashboard')
|
||||
save()
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
visited.value = visited.value.filter(t => t.path === '/dashboard')
|
||||
save()
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem('tags', JSON.stringify(visited.value))
|
||||
}
|
||||
|
||||
return { visited, addTag, removeTag, closeOther, closeAll }
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface UserInfo {
|
||||
username?: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载用户信息
|
||||
*/
|
||||
function loadUserInfo() {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
const data = JSON.parse(userStr)
|
||||
// 兼容不同后端返回格式:可能直接是用户对象,也可能是 token 对象
|
||||
userInfo.value = {
|
||||
username: data.username || data.user_name || data.name || 'Admin',
|
||||
nickname: data.nickname || data.nickName || data.username || data.user_name || 'Admin',
|
||||
avatar: data.avatar || data.headImgUrl || '',
|
||||
...data,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
userInfo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const username = computed(() => userInfo.value?.nickname || userInfo.value?.username || 'Admin')
|
||||
const avatar = computed(() => userInfo.value?.avatar || '')
|
||||
|
||||
function clearUser() {
|
||||
userInfo.value = null
|
||||
}
|
||||
|
||||
// 初始化时加载
|
||||
loadUserInfo()
|
||||
|
||||
return { userInfo, username, avatar, loadUserInfo, clearUser }
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 系统构建配置
|
||||
*/
|
||||
export interface BuildConfig {
|
||||
/** 系统唯一标识,产物目录名 */
|
||||
key: string
|
||||
/** 系统显示名称 */
|
||||
name: string
|
||||
/** 系统描述 */
|
||||
description?: string
|
||||
/** 包含的模块列表 */
|
||||
modules: string[]
|
||||
/** 登录页配置 */
|
||||
login: LoginConfig
|
||||
/** Dashboard配置 */
|
||||
dashboard: DashboardConfig
|
||||
/** 主题配置 */
|
||||
theme: ThemeConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录页配置
|
||||
*/
|
||||
export interface LoginConfig {
|
||||
/** 登录组件名(对应 views/login/systems/ 下的组件) */
|
||||
component: string
|
||||
/** 是否显示租户ID输入 */
|
||||
showTenantInput: boolean
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle?: string
|
||||
/** 背景图路径 */
|
||||
background?: string
|
||||
/** Logo路径 */
|
||||
logo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard配置
|
||||
*/
|
||||
export interface DashboardConfig {
|
||||
/** Dashboard组件名(对应 views/dashboard/systems/ 下的组件) */
|
||||
component: string
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*/
|
||||
export interface ThemeConfig {
|
||||
/** 主题色 */
|
||||
primaryColor: string
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟模块:生成的路由配置
|
||||
*/
|
||||
declare module 'virtual:generated-routes' {
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
const routes: RouteRecordRaw[]
|
||||
export default routes
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟模块:系统配置
|
||||
*/
|
||||
declare module 'virtual:system-config' {
|
||||
import type { BuildConfig } from './system-config'
|
||||
const config: BuildConfig
|
||||
export default config
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import type { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// 全局 loading 实例
|
||||
let loadingInstance: LoadingInstance | null = null
|
||||
let requestCount = 0
|
||||
|
||||
/**
|
||||
* 显示 loading
|
||||
*/
|
||||
function showLoading() {
|
||||
if (requestCount === 0) {
|
||||
loadingInstance = ElLoading.service({
|
||||
lock: true,
|
||||
text: '加载中...',
|
||||
background: 'rgba(0, 0, 0, 0.1)',
|
||||
})
|
||||
}
|
||||
requestCount++
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏 loading
|
||||
*/
|
||||
function hideLoading() {
|
||||
requestCount--
|
||||
if (requestCount <= 0) {
|
||||
requestCount = 0
|
||||
if (loadingInstance) {
|
||||
loadingInstance.close()
|
||||
loadingInstance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不需要显示 loading 的接口白名单
|
||||
const noLoadingUrls = ['/oauth2/token']
|
||||
|
||||
// 请求拦截
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 显示 loading(白名单接口除外)
|
||||
if (!noLoadingUrls.some(url => config.url?.includes(url))) {
|
||||
showLoading()
|
||||
}
|
||||
|
||||
// 透传租户编号至后端(优先级:Token中 > localStorage > 环境变量)
|
||||
let tenantId = localStorage.getItem('tenantId') || import.meta.env.VITE_TENANT_ID
|
||||
const tokenStr = localStorage.getItem('token')
|
||||
if (tokenStr) {
|
||||
try {
|
||||
const token = JSON.parse(tokenStr)
|
||||
config.headers.Authorization = `Bearer ${token.access_token}`
|
||||
// Token中如有租户编号则使用token中的值,用于一键登录
|
||||
if (token.tenantId) {
|
||||
tenantId = token.tenantId
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Token解析失败', e)
|
||||
}
|
||||
}
|
||||
if (tenantId) {
|
||||
config.headers['X-Tenant-Id'] = tenantId
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
hideLoading()
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 响应拦截
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
hideLoading()
|
||||
const { data } = response
|
||||
|
||||
// 业务错误处理
|
||||
if (data.error !== 0) {
|
||||
// 特殊错误码处理
|
||||
if (data.code === 403) {
|
||||
ElMessage.error('无权访问该资源')
|
||||
} else {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
}
|
||||
return Promise.reject(data)
|
||||
}
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
hideLoading()
|
||||
|
||||
// 网络错误处理
|
||||
if (!error.response) {
|
||||
if (error.message?.includes('timeout')) {
|
||||
ElMessage.error('请求超时,请稍后重试')
|
||||
} else if (error.message?.includes('Network Error')) {
|
||||
ElMessage.error('网络连接失败,请检查网络')
|
||||
} else {
|
||||
ElMessage.error('服务器连接失败')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// HTTP 状态码处理
|
||||
const status = error.response.status
|
||||
switch (status) {
|
||||
case 400:
|
||||
ElMessage.error('请求参数错误')
|
||||
break
|
||||
case 401:
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('tenantId')
|
||||
window.location.hash = '#/login'
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('无权访问该资源')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
case 502:
|
||||
ElMessage.error('网关错误')
|
||||
break
|
||||
case 503:
|
||||
ElMessage.error('服务暂不可用')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(`请求失败: ${status}`)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export { request }
|
||||
export default request
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { orderService } from '@/service/cashier/orderService'
|
||||
import RuiTable from '@/components/RuiTable.vue'
|
||||
import type { TableColumn } from '@/components/RuiTable.vue'
|
||||
import OpenRoomDialog from './OpenRoomDialog.vue'
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
const columns: TableColumn[] = [
|
||||
{ prop: 'orderNo', label: '订单编号', width: 150 },
|
||||
{ prop: 'storeId', label: '门店ID', width: 100 },
|
||||
{ prop: 'roomId', label: '包间ID', width: 100 },
|
||||
{ prop: 'customerName', label: '顾客姓名', width: 100 },
|
||||
{ prop: 'customerPhone', label: '顾客电话', width: 130 },
|
||||
{ prop: 'totalAmount', label: '总金额', width: 100, align: 'right', dataType: 'money' },
|
||||
{ prop: 'payAmount', label: '实付金额', width: 100, align: 'right', dataType: 'money' },
|
||||
{
|
||||
prop: 'payStatus',
|
||||
label: '支付状态',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '未支付', 1: '部分支付', 2: '已支付' },
|
||||
},
|
||||
{ prop: 'payType', label: '支付方式', width: 100 },
|
||||
{
|
||||
prop: 'status',
|
||||
label: '订单状态',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '开台中', 1: '已挂单', 2: '待支付', 3: '已完成', 4: '已退款' },
|
||||
},
|
||||
{ prop: 'createTime', label: '创建时间', width: 160, dataType: 'dateTime' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 表格引用
|
||||
*/
|
||||
const tableRef = ref<InstanceType<typeof RuiTable>>()
|
||||
|
||||
const openRoomVisible = ref(false)
|
||||
|
||||
/**
|
||||
* 查询参数
|
||||
*/
|
||||
const queryParams = ref({
|
||||
orderNo: '',
|
||||
status: undefined as number | undefined,
|
||||
payStatus: undefined as number | undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function loadData(params: any) {
|
||||
return await orderService.page(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function handleSearch() {
|
||||
tableRef.value?.setQueryParams(queryParams.value)
|
||||
tableRef.value?.search()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
orderNo: '',
|
||||
status: undefined,
|
||||
payStatus: undefined,
|
||||
}
|
||||
tableRef.value?.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 开台
|
||||
*/
|
||||
function handleOpen() {
|
||||
openRoomVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 结账
|
||||
*/
|
||||
function handleCheckout(row: any) {
|
||||
ElMessageBox.confirm(`确认为订单 "${row.orderNo}" 结账吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await orderService.checkout(row.id)
|
||||
ElMessage.success('结账成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款
|
||||
*/
|
||||
function handleRefund(row: any) {
|
||||
ElMessageBox.prompt('请输入退款金额', '退款', {
|
||||
inputType: 'number',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
}).then(async ({ value }: any) => {
|
||||
try {
|
||||
await orderService.refund(row.id, { amount: Number(value), reason: '用户申请退款' })
|
||||
ElMessage.success('退款成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="text-xl font-bold mb-4">订单管理</h2>
|
||||
|
||||
<RuiTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:load-data="loadData"
|
||||
>
|
||||
<!-- 查询区域 -->
|
||||
<template #search>
|
||||
<el-form-item label="订单编号">
|
||||
<el-input v-model="queryParams.orderNo" placeholder="请输入订单编号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="订单状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
|
||||
<el-option label="开台中" :value="0" />
|
||||
<el-option label="已挂单" :value="1" />
|
||||
<el-option label="待支付" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已退款" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="支付状态">
|
||||
<el-select v-model="queryParams.payStatus" placeholder="请选择状态" clearable>
|
||||
<el-option label="未支付" :value="0" />
|
||||
<el-option label="部分支付" :value="1" />
|
||||
<el-option label="已支付" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<template #toolbar-left>
|
||||
<el-button type="primary" @click="handleOpen">开台</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #action="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 0 || row.status === 1"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleCheckout(row)"
|
||||
>
|
||||
结账
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 3 && row.payStatus === 2"
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handleRefund(row)"
|
||||
>
|
||||
退款
|
||||
</el-button>
|
||||
</template>
|
||||
</RuiTable>
|
||||
|
||||
<OpenRoomDialog
|
||||
v-model:visible="openRoomVisible"
|
||||
@success="tableRef?.refresh()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { orderService } from '@/service/cashier/orderService'
|
||||
import { storeService } from '@/service/cashier/storeService'
|
||||
import { roomService } from '@/service/cashier/roomService'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
storeId: undefined as number | undefined,
|
||||
roomId: undefined as number | undefined,
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
orderType: 1,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 门店列表
|
||||
const storeList = ref<any[]>([])
|
||||
|
||||
// 包间列表
|
||||
const roomList = ref<any[]>([])
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
loadStores()
|
||||
Object.assign(form, {
|
||||
storeId: undefined,
|
||||
roomId: undefined,
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
orderType: 1,
|
||||
remark: '',
|
||||
})
|
||||
roomList.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// 加载门店列表
|
||||
async function loadStores() {
|
||||
try {
|
||||
const res = await storeService.list({ status: 1 })
|
||||
storeList.value = res || []
|
||||
} catch {
|
||||
storeList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 门店变化时加载包间列表
|
||||
async function handleStoreChange(storeId: number) {
|
||||
form.roomId = undefined
|
||||
if (!storeId) {
|
||||
roomList.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await roomService.list({ storeId, enabled: 1 })
|
||||
roomList.value = res || []
|
||||
} catch {
|
||||
roomList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
storeId: [
|
||||
{ required: true, message: '请选择门店', trigger: 'change' },
|
||||
],
|
||||
roomId: [
|
||||
{ required: true, message: '请选择包间', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await orderService.openRoom({
|
||||
storeId: form.storeId!,
|
||||
roomId: form.roomId!,
|
||||
customerName: form.customerName || undefined,
|
||||
customerPhone: form.customerPhone || undefined,
|
||||
orderType: form.orderType,
|
||||
remark: form.remark || undefined,
|
||||
})
|
||||
ElMessage.success('开台成功')
|
||||
emit('success')
|
||||
emit('update:visible', false)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="rui-dialog"
|
||||
title="开台"
|
||||
:model-value="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="门店" prop="storeId">
|
||||
<el-select
|
||||
v-model="form.storeId"
|
||||
placeholder="请选择门店"
|
||||
clearable
|
||||
@change="handleStoreChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in storeList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="包间" prop="roomId">
|
||||
<el-select v-model="form.roomId" placeholder="请选择包间" clearable>
|
||||
<el-option
|
||||
v-for="item in roomList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="顾客姓名">
|
||||
<el-input v-model.trim="form.customerName" placeholder="请输入顾客姓名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="顾客电话">
|
||||
<el-input v-model.trim="form.customerPhone" placeholder="请输入顾客电话" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="订单类型">
|
||||
<el-radio-group v-model="form.orderType">
|
||||
<el-radio-button :label="1">正常订单</el-radio-button>
|
||||
<el-radio-button :label="2">预订订单</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model.trim="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { pricingService } from '@/service/cashier/pricingService'
|
||||
import RuiTable from '@/components/RuiTable.vue'
|
||||
import type { TableColumn } from '@/components/RuiTable.vue'
|
||||
import PricingStrategyFormDialog from './PricingStrategyFormDialog.vue'
|
||||
import PricingPackageDialog from './PricingPackageDialog.vue'
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
const columns: TableColumn[] = [
|
||||
{ prop: 'strategyName', label: '策略名称', minWidth: 150 },
|
||||
{ prop: 'roomTypeId', label: '包间类型ID', width: 120 },
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '禁用', 1: '启用' },
|
||||
slot: true,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 表格引用
|
||||
*/
|
||||
const tableRef = ref<InstanceType<typeof RuiTable>>()
|
||||
|
||||
/**
|
||||
* 查询参数
|
||||
*/
|
||||
const queryParams = ref({
|
||||
strategyName: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* 策略表单弹窗状态
|
||||
*/
|
||||
const strategyDialogVisible = ref(false)
|
||||
const strategyRow = ref<any>(undefined)
|
||||
|
||||
/**
|
||||
* 套餐管理弹窗状态
|
||||
*/
|
||||
const packageDialogVisible = ref(false)
|
||||
const packageStrategyId = ref<number | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function loadData(params: any) {
|
||||
return await pricingService.page(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function handleSearch() {
|
||||
tableRef.value?.setQueryParams(queryParams.value)
|
||||
tableRef.value?.search()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
strategyName: '',
|
||||
status: undefined,
|
||||
}
|
||||
tableRef.value?.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
function handleAdd() {
|
||||
strategyRow.value = undefined
|
||||
strategyDialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
function handleEdit(row: any) {
|
||||
strategyRow.value = row
|
||||
strategyDialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
function handleDelete(row: any) {
|
||||
ElMessageBox.confirm(`确认删除策略 "${row.strategyName}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await pricingService.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态切换
|
||||
*/
|
||||
async function handleStatusChange(row: any, status: number) {
|
||||
if (!row?.id) return
|
||||
try {
|
||||
await pricingService.changeStatus(row.id, status)
|
||||
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
|
||||
} catch {
|
||||
row.status = status === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看套餐
|
||||
*/
|
||||
function handleViewPackages(row: any) {
|
||||
packageStrategyId.value = row.id
|
||||
packageDialogVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="text-xl font-bold mb-4">定价策略管理</h2>
|
||||
|
||||
<RuiTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:load-data="loadData"
|
||||
>
|
||||
<!-- 查询区域 -->
|
||||
<template #search>
|
||||
<el-form-item label="策略名称">
|
||||
<el-input v-model="queryParams.strategyName" placeholder="请输入策略名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<template #toolbar-left>
|
||||
<el-button type="primary" @click="handleAdd">新增策略</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template #column-status="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="(val: any) => handleStatusChange(row, val as number)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #action="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="info" size="small" @click="handleViewPackages(row)">查看套餐</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</RuiTable>
|
||||
|
||||
<PricingStrategyFormDialog
|
||||
v-model:visible="strategyDialogVisible"
|
||||
:row="strategyRow"
|
||||
@success="tableRef?.refresh()"
|
||||
/>
|
||||
|
||||
<PricingPackageDialog
|
||||
v-model:visible="packageDialogVisible"
|
||||
:strategy-id="packageStrategyId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { pricingService } from '@/service/cashier/pricingService'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
strategyId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
// 套餐列表
|
||||
const packages = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 内层表单弹窗
|
||||
const formDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const formLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
price: undefined as number | undefined,
|
||||
duration: undefined as number | undefined,
|
||||
durationUnit: 'hour',
|
||||
billingType: 1,
|
||||
minDuration: undefined as number | undefined,
|
||||
isDefault: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
description: '',
|
||||
strategyId: undefined as number | undefined,
|
||||
})
|
||||
|
||||
// 监听 visible 变化,加载数据
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (val && props.strategyId) {
|
||||
await loadPackages()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载套餐列表
|
||||
async function loadPackages() {
|
||||
if (!props.strategyId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await pricingService.getPackages(props.strategyId)
|
||||
packages.value = list
|
||||
} catch {
|
||||
packages.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新增弹窗
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
price: undefined,
|
||||
duration: undefined,
|
||||
durationUnit: 'hour',
|
||||
billingType: 1,
|
||||
minDuration: undefined,
|
||||
isDefault: 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
description: '',
|
||||
strategyId: props.strategyId,
|
||||
})
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, row)
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除套餐
|
||||
function handleDelete(row: any) {
|
||||
ElMessageBox.confirm(`确认删除套餐 "${row.name}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await pricingService.deletePackage(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadPackages()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入套餐名称', trigger: 'blur' },
|
||||
],
|
||||
price: [
|
||||
{ required: true, message: '请输入价格', trigger: 'blur' },
|
||||
],
|
||||
duration: [
|
||||
{ required: true, message: '请输入时长', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await pricingService.updatePackage(form as any)
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await pricingService.addPackage(form)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
formDialogVisible.value = false
|
||||
await loadPackages()
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 关闭内层弹窗
|
||||
function handleFormClose() {
|
||||
formDialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="rui-dialog"
|
||||
title="套餐管理"
|
||||
:model-value="visible"
|
||||
width="900px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">新增套餐</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="packages" v-loading="loading" border>
|
||||
<el-table-column prop="name" label="套餐名称" min-width="150" />
|
||||
<el-table-column prop="price" label="价格" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="时长" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.duration }}{{ row.durationUnit === 'hour' ? '小时' : '天' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billingType" label="计费类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.billingType === 1" size="small">按时计费</el-tag>
|
||||
<el-tag v-else-if="row.billingType === 2" size="small" type="success">按局计费</el-tag>
|
||||
<el-tag v-else-if="row.billingType === 3" size="small" type="warning">包时段</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isDefault" label="默认" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDefault === 1" size="small" type="success">是</el-tag>
|
||||
<el-tag v-else size="small" type="info">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.status === 1" size="small" type="success">启用</el-tag>
|
||||
<el-tag v-else size="small" type="danger">禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 内层表单弹窗 -->
|
||||
<el-dialog
|
||||
v-model="formDialogVisible"
|
||||
:title="isEdit ? '编辑套餐' : '新增套餐'"
|
||||
width="600px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
@close="handleFormClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="套餐名称" prop="name">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入套餐名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="价格" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0" :precision="2" placeholder="请输入价格" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时长" prop="duration">
|
||||
<el-input-number v-model="form.duration" :min="1" placeholder="请输入时长" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时长单位" prop="durationUnit">
|
||||
<el-radio-group v-model="form.durationUnit">
|
||||
<el-radio-button label="hour">小时</el-radio-button>
|
||||
<el-radio-button label="day">天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="计费类型" prop="billingType">
|
||||
<el-radio-group v-model="form.billingType">
|
||||
<el-radio-button :label="1">按时计费</el-radio-button>
|
||||
<el-radio-button :label="2">按局计费</el-radio-button>
|
||||
<el-radio-button :label="3">包时段</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最小时长" prop="minDuration">
|
||||
<el-input-number v-model="form.minDuration" :min="0" placeholder="请输入最小时长" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否默认" prop="isDefault">
|
||||
<el-radio-group v-model="form.isDefault">
|
||||
<el-radio-button :label="1">是</el-radio-button>
|
||||
<el-radio-button :label="0">否</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" placeholder="请输入排序" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button :label="1">启用</el-radio-button>
|
||||
<el-radio-button :label="0">禁用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model.trim="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleFormClose">取消</el-button>
|
||||
<el-button type="primary" :loading="formLoading" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pricingService } from '@/service/cashier/pricingService'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
row?: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
strategyName: '',
|
||||
roomTypeId: undefined as number | undefined,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否编辑模式
|
||||
const isEdit = ref(false)
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
if (props.row) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, props.row)
|
||||
} else {
|
||||
isEdit.value = false
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
strategyName: '',
|
||||
roomTypeId: undefined,
|
||||
status: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
strategyName: [
|
||||
{ required: true, message: '请输入策略名称', trigger: 'blur' },
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await pricingService.update(form as any)
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await pricingService.add(form)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
emit('success')
|
||||
emit('update:visible', false)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="rui-dialog"
|
||||
:title="isEdit ? '编辑定价策略' : '新增定价策略'"
|
||||
:model-value="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="策略名称" prop="strategyName">
|
||||
<el-input v-model.trim="form.strategyName" placeholder="请输入策略名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="包间类型ID" prop="roomTypeId">
|
||||
<el-input-number v-model="form.roomTypeId" :min="0" placeholder="请输入包间类型ID" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button :label="1">启用</el-radio-button>
|
||||
<el-radio-button :label="0">禁用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { productService } from '@/service/cashier/productService'
|
||||
import RuiTable from '@/components/RuiTable.vue'
|
||||
import type { TableColumn } from '@/components/RuiTable.vue'
|
||||
import ProductFormDialog from './ProductFormDialog.vue'
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
const columns: TableColumn[] = [
|
||||
{ prop: 'productName', label: '商品名称', minWidth: 150 },
|
||||
{ prop: 'productCode', label: '商品编码', width: 120 },
|
||||
{ prop: 'categoryId', label: '分类ID', width: 100 },
|
||||
{
|
||||
prop: 'productType',
|
||||
label: '类型',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 1: '普通商品', 2: '服务商品' },
|
||||
},
|
||||
{ prop: 'unit', label: '单位', width: 80 },
|
||||
{ prop: 'salePrice', label: '售价', width: 100, align: 'right', dataType: 'money' },
|
||||
{ prop: 'costPrice', label: '成本价', width: 100, align: 'right', dataType: 'money' },
|
||||
{ prop: 'sort', label: '排序', width: 80, align: 'center' },
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '禁用', 1: '启用' },
|
||||
slot: true,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 表格引用
|
||||
*/
|
||||
const tableRef = ref<InstanceType<typeof RuiTable>>()
|
||||
|
||||
/**
|
||||
* 弹窗状态
|
||||
*/
|
||||
const dialogVisible = ref(false)
|
||||
const currentRow = ref<any>()
|
||||
|
||||
/**
|
||||
* 查询参数
|
||||
*/
|
||||
const queryParams = ref({
|
||||
productName: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function loadData(params: any) {
|
||||
return await productService.page(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function handleSearch() {
|
||||
tableRef.value?.setQueryParams(queryParams.value)
|
||||
tableRef.value?.search()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
productName: '',
|
||||
status: undefined,
|
||||
}
|
||||
tableRef.value?.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
function handleAdd() {
|
||||
currentRow.value = undefined
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
function handleEdit(row: any) {
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
function handleDelete(row: any) {
|
||||
ElMessageBox.confirm(`确认删除商品 "${row.productName}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await productService.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态切换
|
||||
*/
|
||||
async function handleStatusChange(row: any, status: number) {
|
||||
if (!row?.id) return
|
||||
try {
|
||||
await productService.changeStatus(row.id, status)
|
||||
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
|
||||
} catch {
|
||||
row.status = status === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="text-xl font-bold mb-4">商品管理</h2>
|
||||
|
||||
<RuiTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:load-data="loadData"
|
||||
>
|
||||
<!-- 查询区域 -->
|
||||
<template #search>
|
||||
<el-form-item label="商品名称">
|
||||
<el-input v-model="queryParams.productName" placeholder="请输入商品名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<template #toolbar-left>
|
||||
<el-button type="primary" @click="handleAdd">新增商品</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template #column-status="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="(val: any) => handleStatusChange(row, val as number)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #action="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</RuiTable>
|
||||
|
||||
<ProductFormDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:row="currentRow"
|
||||
@success="tableRef?.refresh()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { productService } from '@/service/cashier/productService'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
row?: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
// 是否编辑模式
|
||||
const isEdit = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
price: undefined as number | undefined,
|
||||
productType: 1,
|
||||
category: '',
|
||||
unit: '',
|
||||
stock: undefined as number | undefined,
|
||||
status: 1,
|
||||
description: '',
|
||||
storeId: undefined as number | undefined,
|
||||
})
|
||||
|
||||
// 是否显示库存字段(仅实物商品)
|
||||
const showStock = computed(() => form.productType === 1)
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
if (props.row) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, props.row)
|
||||
} else {
|
||||
isEdit.value = false
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
price: undefined,
|
||||
productType: 1,
|
||||
category: '',
|
||||
unit: '',
|
||||
stock: undefined,
|
||||
status: 1,
|
||||
description: '',
|
||||
storeId: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入商品名称', trigger: 'blur' },
|
||||
],
|
||||
price: [
|
||||
{ required: true, message: '请输入商品价格', trigger: 'blur' },
|
||||
],
|
||||
productType: [
|
||||
{ required: true, message: '请选择商品类型', trigger: 'change' },
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await productService.update(form as any)
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await productService.add(form)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
emit('success')
|
||||
emit('update:visible', false)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="rui-dialog"
|
||||
:title="isEdit ? '编辑商品' : '新增商品'"
|
||||
:model-value="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="商品名称" prop="name">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入商品名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="商品类型" prop="productType">
|
||||
<el-radio-group v-model="form.productType">
|
||||
<el-radio-button :label="1">实物商品</el-radio-button>
|
||||
<el-radio-button :label="2">服务商品</el-radio-button>
|
||||
<el-radio-button :label="3">虚拟商品</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="售价" prop="price">
|
||||
<el-input-number
|
||||
v-model="form.price"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
placeholder="请输入售价"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类" prop="category">
|
||||
<el-input v-model.trim="form.category" placeholder="请输入分类" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-input v-model.trim="form.unit" placeholder="如:个、杯、小时" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="showStock" label="库存" prop="stock">
|
||||
<el-input-number
|
||||
v-model="form.stock"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
placeholder="请输入库存数量"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button :label="1">启用</el-radio-button>
|
||||
<el-radio-button :label="0">禁用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model.trim="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入商品描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,496 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reportService } from '@/service/cashier/reportService'
|
||||
|
||||
/**
|
||||
* 查询参数
|
||||
*/
|
||||
const queryParams = ref({
|
||||
storeId: 1,
|
||||
dateRange: [new Date().toISOString().split('T')[0], new Date().toISOString().split('T')[0]] as string[],
|
||||
})
|
||||
|
||||
/**
|
||||
* 日报数据
|
||||
*/
|
||||
const dailyReport = ref<any>(null)
|
||||
|
||||
/**
|
||||
* 包间利用率
|
||||
*/
|
||||
const roomUsageList = ref<any[]>([])
|
||||
|
||||
/**
|
||||
* 加载状态
|
||||
*/
|
||||
const loadingDaily = ref(false)
|
||||
const loadingRoom = ref(false)
|
||||
|
||||
/**
|
||||
* 计算当前查询日期
|
||||
*/
|
||||
const currentDate = computed(() => {
|
||||
if (Array.isArray(queryParams.value.dateRange) && queryParams.value.dateRange.length > 0) {
|
||||
return queryParams.value.dateRange[0]
|
||||
}
|
||||
return new Date().toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
*/
|
||||
function formatMoney(value: number | undefined): string {
|
||||
if (value === undefined || value === null) return '0.00'
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取趋势数据(模拟)
|
||||
*/
|
||||
function getTrend(field: string): number {
|
||||
// 实际应从昨日数据计算,此处为演示返回模拟值
|
||||
const mockTrends: Record<string, number> = {
|
||||
totalAmount: 12.5,
|
||||
payAmount: 8.3,
|
||||
orderCount: -2.1,
|
||||
completedOrderCount: 5.0,
|
||||
avgAmount: 15.2,
|
||||
refundAmount: -5.8,
|
||||
}
|
||||
return mockTrends[field] || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载日报
|
||||
*/
|
||||
async function loadDailyReport() {
|
||||
loadingDaily.value = true
|
||||
try {
|
||||
dailyReport.value = await reportService.getDailyReport(
|
||||
queryParams.value.storeId,
|
||||
currentDate.value
|
||||
)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loadingDaily.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载包间利用率
|
||||
*/
|
||||
async function loadRoomUsage() {
|
||||
loadingRoom.value = true
|
||||
try {
|
||||
roomUsageList.value = await reportService.getRoomUsage(
|
||||
queryParams.value.storeId,
|
||||
currentDate.value
|
||||
)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loadingRoom.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function handleSearch() {
|
||||
loadDailyReport()
|
||||
loadRoomUsage()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
function handleRefresh() {
|
||||
handleSearch()
|
||||
ElMessage.success('数据已刷新')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报表
|
||||
*/
|
||||
function handleExport() {
|
||||
ElMessage.info('导出功能开发中')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleSearch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h2 class="text-xl font-bold">营业报表</h2>
|
||||
<div class="header-actions">
|
||||
<el-button icon="Refresh" @click="handleRefresh">
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="success" icon="Download" @click="handleExport">
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询表单 -->
|
||||
<el-form :model="queryParams" inline class="search-form">
|
||||
<el-form-item label="门店ID">
|
||||
<el-input-number v-model="queryParams.storeId" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="日期范围">
|
||||
<el-date-picker
|
||||
v-model="queryParams.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:shortcuts="[
|
||||
{ text: '今天', value: [new Date(), new Date()] },
|
||||
{ text: '昨天', value: () => { const d = new Date(); d.setDate(d.getDate() - 1); return [d, d] } },
|
||||
{ text: '最近7天', value: () => { const d = new Date(); d.setDate(d.getDate() - 7); return [d, new Date()] } },
|
||||
{ text: '最近30天', value: () => { const d = new Date(); d.setDate(d.getDate() - 30); return [d, new Date()] } },
|
||||
]"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="handleSearch">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 日报数据卡片 -->
|
||||
<el-row :gutter="16" class="data-cards">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
|
||||
<el-card v-loading="loadingDaily" class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>订单总数</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="card-value">{{ dailyReport?.orderCount || 0 }}</div>
|
||||
<div v-if="dailyReport" class="card-trend">
|
||||
<el-icon v-if="getTrend('orderCount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
|
||||
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
|
||||
<span :class="getTrend('orderCount') >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ Math.abs(getTrend('orderCount')) }}% 较昨日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
|
||||
<el-card v-loading="loadingDaily" class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<span>已完成订单</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="card-value">{{ dailyReport?.completedOrderCount || 0 }}</div>
|
||||
<div v-if="dailyReport" class="card-trend">
|
||||
<el-icon v-if="getTrend('completedOrderCount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
|
||||
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
|
||||
<span :class="getTrend('completedOrderCount') >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ Math.abs(getTrend('completedOrderCount')) }}% 较昨日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
|
||||
<el-card v-loading="loadingDaily" class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Money /></el-icon>
|
||||
<span>总营业额</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="card-value text-success">¥{{ formatMoney(dailyReport?.totalAmount) }}</div>
|
||||
<div v-if="dailyReport" class="card-trend">
|
||||
<el-icon v-if="getTrend('totalAmount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
|
||||
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
|
||||
<span :class="getTrend('totalAmount') >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ Math.abs(getTrend('totalAmount')) }}% 较昨日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
|
||||
<el-card v-loading="loadingDaily" class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span>实付金额</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="card-value text-success">¥{{ formatMoney(dailyReport?.payAmount) }}</div>
|
||||
<div v-if="dailyReport" class="card-trend">
|
||||
<el-icon v-if="getTrend('payAmount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
|
||||
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
|
||||
<span :class="getTrend('payAmount') >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ Math.abs(getTrend('payAmount')) }}% 较昨日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
|
||||
<el-card v-loading="loadingDaily" class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>客单价</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="card-value">¥{{ formatMoney(dailyReport?.avgAmount) }}</div>
|
||||
<div v-if="dailyReport" class="card-trend">
|
||||
<el-icon v-if="getTrend('avgAmount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
|
||||
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
|
||||
<span :class="getTrend('avgAmount') >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ Math.abs(getTrend('avgAmount')) }}% 较昨日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
|
||||
<el-card v-loading="loadingDaily" class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Refund /></el-icon>
|
||||
<span>退款金额</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="card-value text-danger">¥{{ formatMoney(dailyReport?.refundAmount) }}</div>
|
||||
<div v-if="dailyReport" class="card-trend">
|
||||
<el-icon v-if="getTrend('refundAmount') >= 0" color="#f56c6c"><ArrowUp /></el-icon>
|
||||
<el-icon v-else color="#67c23a"><ArrowDown /></el-icon>
|
||||
<span :class="getTrend('refundAmount') >= 0 ? 'text-danger' : 'text-success'">
|
||||
{{ Math.abs(getTrend('refundAmount')) }}% 较昨日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 收入明细 -->
|
||||
<el-card class="mt-4" v-loading="loadingDaily">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>收入明细</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" />
|
||||
<el-row :gutter="16" v-if="dailyReport">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<div class="income-item">
|
||||
<span class="label">
|
||||
<el-icon><House /></el-icon> 包间收入
|
||||
</span>
|
||||
<span class="value text-success">¥{{ formatMoney(dailyReport.roomAmount) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<div class="income-item">
|
||||
<span class="label">
|
||||
<el-icon><Goods /></el-icon> 商品收入
|
||||
</span>
|
||||
<span class="value text-success">¥{{ formatMoney(dailyReport.productAmount) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<div class="income-item">
|
||||
<span class="label">
|
||||
<el-icon><Discount /></el-icon> 优惠金额
|
||||
</span>
|
||||
<span class="value text-danger">-¥{{ formatMoney(dailyReport.discountAmount) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 支付方式统计 -->
|
||||
<el-card class="mt-4" v-loading="loadingDaily">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
<span>支付方式统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="!dailyReport?.payTypeStats?.length && !loadingDaily" description="暂无数据" />
|
||||
<el-table
|
||||
v-if="dailyReport?.payTypeStats?.length"
|
||||
:data="dailyReport.payTypeStats"
|
||||
border
|
||||
stripe
|
||||
highlight-current-row
|
||||
>
|
||||
<el-table-column prop="payType" label="支付方式" min-width="120" />
|
||||
<el-table-column prop="count" label="笔数" align="center" width="100" />
|
||||
<el-table-column prop="amount" label="金额" align="right" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="(row.amount || 0) >= 0 ? 'text-success' : 'text-danger'">
|
||||
¥{{ formatMoney(row.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="占比" align="center" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="dailyReport.payAmount ? Math.round((row.amount / dailyReport.payAmount) * 100) : 0"
|
||||
:stroke-width="8"
|
||||
:show-text="true"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 包间利用率 -->
|
||||
<el-card class="mt-4" v-loading="loadingRoom">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>包间利用率</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="!roomUsageList.length && !loadingRoom" description="暂无数据" />
|
||||
<el-table
|
||||
v-if="roomUsageList.length"
|
||||
:data="roomUsageList"
|
||||
border
|
||||
stripe
|
||||
highlight-current-row
|
||||
>
|
||||
<el-table-column prop="roomId" label="包间ID" width="100" />
|
||||
<el-table-column prop="orderCount" label="订单数" width="100" align="center" />
|
||||
<el-table-column prop="totalAmount" label="营业额" align="right" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-success">¥{{ formatMoney(row.totalAmount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="usageMinutes" label="使用时长(分钟)" width="140" align="center" />
|
||||
<el-table-column prop="idleMinutes" label="空闲时长(分钟)" width="140" align="center" />
|
||||
<el-table-column prop="usageRate" label="利用率" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="Math.min(Math.round(row.usageRate || 0), 100)"
|
||||
:stroke-width="10"
|
||||
:status="(row.usageRate || 0) >= 80 ? 'success' : (row.usageRate || 0) >= 50 ? '' : 'exception'"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.data-cards {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-body {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.card-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.income-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.income-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.income-item .label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #666;
|
||||
}
|
||||
.income-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { roomService } from '@/service/cashier/roomService'
|
||||
import RuiTable from '@/components/RuiTable.vue'
|
||||
import type { TableColumn } from '@/components/RuiTable.vue'
|
||||
import RoomFormDialog from './RoomFormDialog.vue'
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
const columns: TableColumn[] = [
|
||||
{ prop: 'roomName', label: '包间名称', minWidth: 150 },
|
||||
{ prop: 'roomNo', label: '包间编号', width: 120 },
|
||||
{ prop: 'storeId', label: '门店ID', width: 100 },
|
||||
{ prop: 'roomTypeId', label: '包间类型ID', width: 120 },
|
||||
{
|
||||
prop: 'roomStatus',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '空闲', 1: '使用中', 2: '待清洁', 3: '已挂单', 4: '已预约' },
|
||||
},
|
||||
{ prop: 'currentOrderId', label: '当前订单ID', width: 120 },
|
||||
{ prop: 'sort', label: '排序', width: 80, align: 'center' },
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: '启用',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '禁用', 1: '启用' },
|
||||
slot: true,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 表格引用
|
||||
*/
|
||||
const tableRef = ref<InstanceType<typeof RuiTable>>()
|
||||
|
||||
/**
|
||||
* 弹窗相关
|
||||
*/
|
||||
const dialogVisible = ref(false)
|
||||
const currentRow = ref<any>()
|
||||
|
||||
/**
|
||||
* 查询参数
|
||||
*/
|
||||
const queryParams = ref({
|
||||
roomName: '',
|
||||
roomStatus: undefined as number | undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function loadData(params: any) {
|
||||
return await roomService.page(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function handleSearch() {
|
||||
tableRef.value?.setQueryParams(queryParams.value)
|
||||
tableRef.value?.search()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
roomName: '',
|
||||
roomStatus: undefined,
|
||||
}
|
||||
tableRef.value?.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
function handleAdd() {
|
||||
currentRow.value = undefined
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
function handleEdit(row: any) {
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
function handleDelete(row: any) {
|
||||
ElMessageBox.confirm(`确认删除包间 "${row.roomName}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await roomService.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态切换
|
||||
*/
|
||||
async function handleStatusChange(row: any, status: number) {
|
||||
if (!row?.id) return
|
||||
try {
|
||||
await roomService.changeStatus(row.id, status)
|
||||
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
|
||||
} catch {
|
||||
row.enabled = status === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="text-xl font-bold mb-4">包间管理</h2>
|
||||
|
||||
<RuiTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:load-data="loadData"
|
||||
>
|
||||
<!-- 查询区域 -->
|
||||
<template #search>
|
||||
<el-form-item label="包间名称">
|
||||
<el-input v-model="queryParams.roomName" placeholder="请输入包间名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.roomStatus" placeholder="请选择状态" clearable>
|
||||
<el-option label="空闲" :value="0" />
|
||||
<el-option label="使用中" :value="1" />
|
||||
<el-option label="待清洁" :value="2" />
|
||||
<el-option label="已挂单" :value="3" />
|
||||
<el-option label="已预约" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<template #toolbar-left>
|
||||
<el-button type="primary" @click="handleAdd">新增包间</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 启用列 -->
|
||||
<template #column-enabled="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="(val: any) => handleStatusChange(row, val as number)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #action="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</RuiTable>
|
||||
|
||||
<RoomFormDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:row="currentRow"
|
||||
@success="tableRef?.refresh()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { roomService } from '@/service/cashier/roomService'
|
||||
import { storeService } from '@/service/cashier/storeService'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
row?: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
storeId: undefined as number | undefined,
|
||||
name: '',
|
||||
roomNo: '',
|
||||
sort: 0,
|
||||
enabled: 1,
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否编辑模式
|
||||
const isEdit = ref(false)
|
||||
|
||||
// 门店列表
|
||||
const storeList = ref<any[]>([])
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
loadStoreList()
|
||||
if (props.row) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, props.row)
|
||||
} else {
|
||||
isEdit.value = false
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
storeId: undefined,
|
||||
name: '',
|
||||
roomNo: '',
|
||||
sort: 0,
|
||||
enabled: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 加载门店列表
|
||||
async function loadStoreList() {
|
||||
try {
|
||||
const res = await storeService.list({ status: 1 })
|
||||
storeList.value = res || []
|
||||
} catch {
|
||||
storeList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
storeId: [
|
||||
{ required: true, message: '请选择所属门店', trigger: 'change' },
|
||||
],
|
||||
name: [
|
||||
{ required: true, message: '请输入包间名称', trigger: 'blur' },
|
||||
],
|
||||
roomNo: [
|
||||
{ required: true, message: '请输入包间编号', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await roomService.update(form as any)
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await roomService.add(form)
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
emit('success')
|
||||
emit('update:visible', false)
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="rui-dialog"
|
||||
:title="isEdit ? '编辑包间' : '新增包间'"
|
||||
:model-value="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="所属门店" prop="storeId">
|
||||
<el-select v-model="form.storeId" placeholder="请选择所属门店" clearable>
|
||||
<el-option
|
||||
v-for="item in storeList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="包间名称" prop="name">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入包间名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="包间编号" prop="roomNo">
|
||||
<el-input v-model.trim="form.roomNo" placeholder="请输入包间编号" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" controls-position="right" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="enabled">
|
||||
<el-radio-group v-model="form.enabled">
|
||||
<el-radio-button :label="1">启用</el-radio-button>
|
||||
<el-radio-button :label="0">禁用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { storeService } from '@/service/cashier/storeService'
|
||||
import RuiTable from '@/components/RuiTable.vue'
|
||||
import type { TableColumn } from '@/components/RuiTable.vue'
|
||||
import StoreFormDialog from './StoreFormDialog.vue'
|
||||
|
||||
/**
|
||||
* 表格列配置
|
||||
*/
|
||||
const columns: TableColumn[] = [
|
||||
{ prop: 'storeName', label: '门店名称', minWidth: 150 },
|
||||
{ prop: 'storeCode', label: '门店编码', width: 120 },
|
||||
{ prop: 'contactName', label: '联系人', width: 100 },
|
||||
{ prop: 'contactPhone', label: '联系电话', width: 130 },
|
||||
{ prop: 'address', label: '地址', minWidth: 200, tooltip: true },
|
||||
{ prop: 'businessHours', label: '营业时间', width: 120 },
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
dataType: 'enum',
|
||||
enumMap: { 0: '禁用', 1: '启用' },
|
||||
slot: true,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 表格引用
|
||||
*/
|
||||
const tableRef = ref<InstanceType<typeof RuiTable>>()
|
||||
|
||||
/**
|
||||
* 查询参数
|
||||
*/
|
||||
const queryParams = ref({
|
||||
storeName: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* 弹窗显示状态
|
||||
*/
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
/**
|
||||
* 当前编辑行
|
||||
*/
|
||||
const currentRow = ref<any>()
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
async function loadData(params: any) {
|
||||
return await storeService.page(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function handleSearch() {
|
||||
tableRef.value?.setQueryParams(queryParams.value)
|
||||
tableRef.value?.search()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function handleReset() {
|
||||
queryParams.value = {
|
||||
storeName: '',
|
||||
status: undefined,
|
||||
}
|
||||
tableRef.value?.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
function handleAdd() {
|
||||
currentRow.value = undefined
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
function handleEdit(row: any) {
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
function handleDelete(row: any) {
|
||||
ElMessageBox.confirm(`确认删除门店 "${row.storeName}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await storeService.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
tableRef.value?.refresh()
|
||||
} catch {
|
||||
// 错误已由请求拦截器统一提示
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态切换
|
||||
*/
|
||||
async function handleStatusChange(row: any, status: number) {
|
||||
if (!row?.id) return
|
||||
try {
|
||||
await storeService.changeStatus(row.id, status)
|
||||
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
|
||||
} catch {
|
||||
row.status = status === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="text-xl font-bold mb-4">门店管理</h2>
|
||||
|
||||
<RuiTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:load-data="loadData"
|
||||
>
|
||||
<!-- 查询区域 -->
|
||||
<template #search>
|
||||
<el-form-item label="门店名称">
|
||||
<el-input v-model="queryParams.storeName" placeholder="请输入门店名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<template #toolbar-left>
|
||||
<el-button type="primary" @click="handleAdd">新增门店</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template #column-status="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="(val: any) => handleStatusChange(row, val as number)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #action="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</RuiTable>
|
||||
|
||||
<StoreFormDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:row="currentRow"
|
||||
@success="tableRef?.refresh()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user