Compare commits

..

9 Commits

Author SHA1 Message Date
vifo b11fe9916f test: 测试 v2.0 钉钉通知 2026-06-04 07:13:54 +08:00
vifo d2a1df0e2b test: 验证 JSON 序列化修复 2026-06-04 07:09:53 +08:00
vifo 4fb8c5aec9 test: 测试钉钉 text 消息通知
验证 Webhook 转发和钉钉消息送达
2026-06-04 06:37:23 +08:00
vifo 28d05b8f08 test: 测试 CI 构建和 Webhook 通知
- 验证前端构建工作流
- 验证钉钉 Webhook 推送通知
2026-06-04 06:20:17 +08:00
vifo 6d11711244 ci: 添加前端构建工作流
- 配置 Admin UI 构建
- 配置收银移动端构建
- 配置顾客移动端构建
- 添加 ESLint 和类型检查
2026-06-04 06:13:13 +08:00
vifo 826eefa1ac test: 验证分支保护 2026-06-04 06:09:19 +08:00
vifo 6328b8dbb1 chore: 添加 Gitea CI/CD 配置和 Issue 模板
- 创建 .gitea/workflows/frontend-build.yml(前端构建工作流)
- 创建 .gitea/issue_templates/(Bug 报告、功能需求模板)
- 配置 Gitea Actions 自动化构建
2026-06-04 05:44:00 +08:00
vifo b1dd60ab6e docs: 添加前端开发规范文档和 OpenCode 配置
- 创建 AGENTS.md(前端编码规范、开发流程、Git 提交规范)
- 创建 .opencode/workspace/frontend.json(前端工作区配置)
- 更新 .gitignore
2026-06-04 05:28:50 +08:00
vifo 82a19101a8 chore: 初始化前端仓库并迁移 admin-ui
- 创建 rui-frontend 前端仓库
- 迁移 admin-ui 管理后台
- 创建 cashier-mobile 和 customer-mobile 占位项目
- 配置 pnpm workspace
2026-06-04 05:14:11 +08:00
251 changed files with 22319 additions and 29068 deletions
+36
View File
@@ -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
## 截图
如有截图请粘贴
## 备注
+32
View File
@@ -0,0 +1,32 @@
---
name: 功能需求
title: "[FEATURE] "
labels: ["feature"]
about: 提出新的功能需求
---
## 需求描述
描述需要什么功能
## 使用场景
描述这个功能的使用场景
## 期望实现
描述期望的实现方式
## 优先级
- [ ] P0 - 阻塞
- [ ] P1 - 高
- [ ] P2 - 中
- [ ] P3 - 低
## 关联需求
- 需要后端接口支持:[创建 API-REQ Issue]
- 相关设计稿:
## 备注
+145
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.DS_Store
*.local
.env.production
.cache/
*.log
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+5
View File
@@ -0,0 +1,5 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
</profile>
</component>
+8
View File
@@ -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>
+11
View File
@@ -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 Plusadmin-ui\n- Vite\n- pnpm workspace\n\n## 编码规范\n1. 使用 `<script setup lang=\"ts\">`\n2. Props 和 Emit 必须定义类型\n3. API 服务层封装在 service/ 目录\n4. 状态管理使用 PiniaSetup 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): 中文描述"
}
}
+541
View File
@@ -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。
+1
View File
@@ -0,0 +1 @@
# CI 测试 - 2026-06-04 06:20:17
+1
View File
@@ -0,0 +1 @@
钉钉消息测试 - 2026-06-04 06:37:23
+1
View File
@@ -0,0 +1 @@
# Gitea Test
+1
View File
@@ -0,0 +1 @@
JSON序列化测试 07:09:53
+168 -86
View File
@@ -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 Plusadmin-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
+1
View File
@@ -0,0 +1 @@
v2测试 07:13:54
+6
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
# 管理后台 PC
Vue 3 + TypeScript + Vite + Element Plus + UnoCSS
## 启动
```bash
pnpm install
pnpm dev
```
访问 `http://localhost:3000`
## 构建
```bash
pnpm build
```
+20
View File
@@ -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": "睿核收银"
}
}
+20
View File
@@ -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": "睿核通用平台"
}
}
+20
View File
@@ -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": "睿核平台管理"
}
}
+17
View File
@@ -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>
+44
View File
@@ -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"
}
}
+5948
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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)
},
}
}
+69
View File
@@ -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>
+88
View File
@@ -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')
}
+77
View File
@@ -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>
+211
View File
@@ -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>
+41
View File
@@ -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>
+752
View File
@@ -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
/** 是否使用自定义 slotslot 名为 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>
+218
View File
@@ -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>
+68
View File
@@ -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>
+147
View File
@@ -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,
}
}
+18
View File
@@ -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>
+215
View File
@@ -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>
+202
View File
@@ -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>
+214
View File
@@ -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>
+169
View File
@@ -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>
+107
View File
@@ -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',
},
}
+12
View File
@@ -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
+121
View File
@@ -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: '关闭所有',
},
}
+19
View File
@@ -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')
+36
View File
@@ -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()
})
}
+10
View File
@@ -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
+19
View File
@@ -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 } },
]
+13
View File
@@ -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 } },
]
+38
View File
@@ -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' },
},
],
},
]
+11
View File
@@ -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 } },
]
+11
View File
@@ -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 } },
]
+11
View File
@@ -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 } },
]
+31
View File
@@ -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 } },
]
+19
View File
@@ -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 } },
]
+163
View File
@@ -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
}
}
+100
View File
@@ -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()
+8
View File
@@ -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()
+6
View File
@@ -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()
+13
View File
@@ -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()
+39
View File
@@ -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()
+24
View File
@@ -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 }
})
+158
View File
@@ -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,
}
})
+47
View File
@@ -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 }
})
+46
View File
@@ -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
View File
@@ -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
}
+150
View File
@@ -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
+198
View File
@@ -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>
+496
View File
@@ -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>
+191
View File
@@ -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>
+186
View File
@@ -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