commit 334d2c6312f9a129dd1f806f17fc5c81982c9398 Author: molong Date: Sun Feb 8 22:38:13 2026 +0800 初始化项目 diff --git a/.clinerules/admin-rule.md b/.clinerules/admin-rule.md new file mode 100644 index 0000000..fdc5969 --- /dev/null +++ b/.clinerules/admin-rule.md @@ -0,0 +1,869 @@ +# Vue3 后台管理项目开发规范 + +## 项目概述 + +本项目是一个基于 Vue3 的后台管理系统,采用现代化技术栈构建,提供高效、美观的管理界面。 + +## 技术栈 + +- **Vue 3**: 渐进式 JavaScript 框架 +- **Vite**: 下一代前端构建工具 +- **Ant Design Vue**: 基于 Vue 3 的 UI 组件库 +- **Vue Router**: Vue.js 官方路由管理器 +- **Pinia**: Vue 3 官方状态管理库 +- **Axios**: HTTP 客户端 +- **JavaScript**: 主要开发语言(非 TypeScript) +- **Composition API**: 组合式 API 开发模式 + +## 图标系统 + +项目采用 Ant Design Vue 图标库,已全局引入: + +- **Ant Design Vue Icons**: Ant Design Vue 官方图标库 + +**重要提示**: 图标已全局默认引入,开发时请勿重复引入或按需引入,直接使用即可。 + +## 开发规范 + +### 1. 项目结构 + +``` +resources/admin/ +├── src/ +│ ├── api/ # API 接口层 +│ │ ├── auth.js # 认证相关接口 +│ │ ├── menu.js # 菜单接口 +│ │ └── system.js # 系统相关接口 +│ ├── assets/ # 静态资源 +│ │ ├── images/ # 图片资源 +│ │ └── style/ # 全局样式 +│ ├── components/ # 公共组件 +│ │ ├── scEditor/ # 富文本编辑器 +│ │ ├── scForm/ # 表单组件 +│ │ ├── scIconPicker/ # 图标选择器 +│ │ ├── scTable/ # 表格组件 +│ │ └── scUpload/ # 上传组件 +│ ├── config/ # 配置文件 +│ │ ├── index.js # 主配置 +│ │ ├── routes.js # 路由配置 +│ │ └── upload.js # 上传配置 +│ ├── hooks/ # 组合式 API Hooks +│ │ ├── useI18n.js # 国际化 Hook +│ │ └── useTable.js # 表格 Hook +│ ├── i18n/ # 国际化配置 +│ │ ├── index.js # i18n 配置 +│ │ └── locales/ # 语言包 +│ ├── layouts/ # 布局组件 +│ │ ├── components/ # 布局子组件 +│ │ ├── other/ # 其他布局 +│ │ └── index.vue # 主布局 +│ ├── pages/ # 页面组件 +│ │ ├── auth/ # 认证页面 +│ │ ├── home/ # 首页 +│ │ ├── login/ # 登录页 +│ │ ├── system/ # 系统管理 +│ │ └── ucenter/ # 个人中心 +│ ├── router/ # 路由配置 +│ │ ├── index.js # 主路由 +│ │ └── systemRoutes.js # 系统路由 +│ ├── stores/ # 状态管理 +│ │ ├── index.js # Store 入口 +│ │ ├── persist.js # 持久化配置 +│ │ └── modules/ # Store 模块 +│ ├── utils/ # 工具函数 +│ │ ├── request.js # Axios 封装 +│ │ └── tool.js # 工具函数 +│ ├── App.vue # 根组件 +│ ├── boot.js # 引导文件 +│ ├── main.js # 入口文件 +│ └── style.css # 全局样式 +├── public/ # 公共资源 +├── index.html # HTML 模板 +├── package.json # 依赖配置 +├── vite.config.js # Vite 配置 +└── README.md # 项目说明 +``` + +### 2. 组件开发规范 + +#### 组件命名 + +- **单文件组件**: 使用 PascalCase 命名,如 `UserList.vue` +- **公共组件**: 以 `sc` 开头,如 `scTable.vue` +- **页面组件**: 使用语义化命名,如 `UserManagement.vue` + +#### 组件结构 + +```vue + + + + + +``` + +### 3. API 接口开发规范 + +#### API 文件组织 + +每个业务模块对应一个 API 文件,统一放在 `src/api/` 目录下。 + +```javascript +// src/api/auth.js +import request from '@/utils/request' + +export default { + // 认证相关 + login: { + post: async function (params) { + return await request.post('auth/login', params) + }, + }, + logout: { + post: async function () { + return await request.post('auth/logout') + }, + }, + me: { + get: async function () { + return await request.get('auth/me') + }, + }, + + // 权限和菜单 + permissions: { + menu: { + get: async function () { + return await request.get('permissions/menu') + }, + }, + tree: { + get: async function () { + return await request.get('permissions/tree') + }, + }, + }, +} +``` + +#### 使用示例 + +```javascript +import authApi from '@/api/auth' + +const login = async () => { + try { + const res = await authApi.login.post({ + username: 'admin', + password: '123456' + }) + // 处理响应 + } catch (error) { + // 处理错误 + } +} + +// 获取用户菜单 +const getMenu = async () => { + const res = await authApi.permissions.menu.get() + return res.data +} +``` + +### 4. 路由开发规范 + +#### 路由类型 + +项目包含两类路由: + +1. **静态路由**: 定义在 `src/router/systemRoutes.js` 中的基础路由,如登录页、404 页面等 +2. **动态路由**: 用户登录后,通过 API 获取菜单数据,动态添加到路由中 + +#### 静态路由定义 + +```javascript +// src/router/systemRoutes.js +export default [ + { + path: '/login', + name: 'Login', + component: () => import('@/pages/login/index.vue'), + meta: { + title: '登录', + hidden: true + } + }, + { + path: '/', + name: 'Layout', + component: () => import('@/layouts/index.vue'), + redirect: '/dashboard', + children: [ + // 动态路由将被添加到这里 + ] + } +] +``` + +#### 动态路由加载 + +用户登录后,系统会自动执行以下流程: + +1. 调用后端 API 获取用户菜单和权限信息 +2. 将菜单数据转换为路由格式 +3. 将动态路由添加到路由器中 +4. 生成菜单树用于侧边栏展示 + +**路由元信息 (meta)**: +- `title`: 页面标题 +- `icon`: 菜单图标(使用 Ant Design Vue 图标名称) +- `hidden`: 是否隐藏菜单 +- `noAuth`: 是否不需要认证 +- `keepAlive`: 是否缓存页面 +- `affix`: 是否固定标签页 + +**后端菜单数据格式**: + +```javascript +{ + path: '/system', + name: 'System', + title: '系统管理', + icon: 'Setting', + component: 'views/system', // 组件路径,相对于 pages 目录 + redirect: '/system/user', + children: [ + { + path: 'user', + name: 'SystemUser', + title: '用户管理', + icon: 'User', + component: 'views/system/user' + } + ] +} +``` + +**路由转换逻辑**: + +```javascript +// 将后端菜单转换为路由格式 +function transformMenusToRoutes(menus) { + return menus.map(menu => { + const route = { + path: menu.path, + name: menu.name, + meta: { + title: menu.title, + icon: menu.icon, + hidden: menu.hidden, + keepAlive: menu.keepAlive || false + } + } + + if (menu.component) { + route.component = loadComponent(menu.component) + } + + if (menu.children) { + route.children = transformMenusToRoutes(menu.children) + } + + return route + }) +} +``` + +#### 路由守卫 + +系统通过路由守卫实现权限控制和动态路由加载: + +```javascript +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore() + const isLoggedIn = userStore.isLoggedIn() + + // 白名单直接放行 + if (whiteList.includes(to.path)) { + return next() + } + + // 未登录跳转登录页 + if (!isLoggedIn) { + return next({ path: '/login', query: { redirect: to.fullPath } }) + } + + // 动态路由加载 + if (!isDynamicRouteLoaded) { + const menus = userStore.getMenu() + const dynamicRoutes = transformMenusToRoutes(menus) + + // 添加动态路由 + dynamicRoutes.forEach(route => { + router.addRoute('Layout', route) + }) + + isDynamicRouteLoaded = true + next({ ...to, replace: true }) + } else { + next() + } +}) +``` + +### 5. 状态管理规范 + +#### Pinia Store 定义 + +使用组合式 API 定义 Store。 + +#### User Store(用户认证与权限) + +`src/stores/modules/user.js` 负责管理用户认证信息和权限数据: + +```javascript +// src/stores/modules/user.js +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { resetRouter } from '../../router' + +export const useUserStore = defineStore('user', () => { + // State + const token = ref('') // 访问令牌 + const refreshToken = ref('') // 刷新令牌 + const userInfo = ref(null) // 用户信息 + const menu = ref([]) // 用户菜单 + const permissions = ref([]) // 用户权限节点 + + // Getters + const isLoggedIn = () => !!token.value + + // Actions + function setToken(newToken) { + token.value = newToken + } + + function setUserInfo(info) { + userInfo.value = info + } + + // 设置菜单(合并静态菜单和后端菜单) + function setMenu(newMenu) { + const staticMenus = userRoutes || [] + let mergedMenus = [...staticMenus] + + if (newMenu && newMenu.length > 0) { + const menuMap = new Map() + + // 添加静态菜单 + staticMenus.forEach(m => { + if (m.path) menuMap.set(m.path, m) + }) + + // 添加后端菜单(覆盖重复路径) + newMenu.forEach(m => { + if (m.path) menuMap.set(m.path, m) + }) + + mergedMenus = Array.from(menuMap.values()) + } + menu.value = mergedMenus + } + + function getMenu() { + return menu.value + } + + function setPermissions(data) { + permissions.value = data + } + + function hasPermission(permission) { + if (!permissions.value || permissions.value.length === 0) { + return false + } + return permissions.value.includes(permission) + } + + function logout() { + token.value = '' + refreshToken.value = '' + userInfo.value = null + menu.value = [] + resetRouter() + } + + return { + token, + refreshToken, + userInfo, + menu, + permissions, + setToken, + setUserInfo, + setMenu, + getMenu, + setPermissions, + hasPermission, + logout, + isLoggedIn + } +}) +``` + +#### Store 使用 + +```javascript +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +// 使用 state +console.log(userStore.token) +console.log(userStore.menu) +console.log(userStore.permissions) + +// 调用 action +userStore.setToken('xxx') +userStore.setMenu(menus) +userStore.setPermissions(permissions) + +// 检查权限 +if (userStore.hasPermission('user.create')) { + // 有权限,执行操作 +} + +// 登出 +userStore.logout() +``` + +#### Store 持久化 + +使用 `pinia-plugin-persistedstate` 实现数据持久化: + +```javascript +{ + persist: { + key: 'user-store', + storage: customStorage, + pick: ['token', 'refreshToken', 'userInfo', 'menu'] + } +} +``` + +#### 权限指令 + +项目提供权限指令,用于在模板中控制元素显示: + +```vue + + + +``` + +### 6. 表格开发规范 + +#### 使用 useTable Hook + +项目提供了 `useTable` Hook 简化表格开发: + +```javascript +import { useTable } from '@/hooks/useTable' +import { ref } from 'vue' + +const { + loading, + dataSource, + pagination, + handleSearch, + handleReset, + handlePageChange +} = useTable({ + api: userApi.getList, // API 方法 + immediate: true // 是否立即加载 +}) + +// 搜索参数 +const searchParams = ref({ + keyword: '', + status: '' +}) +``` + +#### scTable 组件使用 + +```vue + +``` + +### 7. 表单开发规范 + +#### scForm 组件使用 + +```vue + + + +``` + +### 8. 图标使用规范 + +#### Ant Design Vue Icons + +```vue + + +``` + +#### 常用图标 + +- `user`: 用户 +- `setting`: 设置 +- `delete`: 删除 +- `edit`: 编辑 +- `plus`: 添加 +- `search`: 搜索 +- `reload`: 刷新 +- `download`: 下载 +- `upload`: 上传 +- `eye`: 查看 +- `eye-invisible`: 隐藏 +- `check-circle`: 成功 +- `close-circle`: 失败 +- `info-circle`: 信息 +- `warning`: 警告 + +### 9. 国际化 (i18n) 规范 + +#### 使用 i18n + +```javascript +import { useI18n } from '@/hooks/useI18n' + +const { t } = useI18n() + +// 使用 +console.log(t('common.save')) +console.log(t('user.deleteConfirm')) +``` + +#### 语言文件组织 + +```javascript +// src/i18n/locales/zh.js +export default { + common: { + save: '保存', + cancel: '取消', + confirm: '确认', + delete: '删除' + }, + user: { + deleteConfirm: '确定要删除该用户吗?' + } +} +``` + +### 10. 文件上传规范 + +#### scUpload 组件使用 + +```vue + + + +``` + +### 11. 富文本编辑器规范 + +#### scEditor 组件使用 + +```vue + + + +``` + +### 12. 样式规范 + +#### 全局样式 + +在 `src/style.css` 中定义全局样式。 + +#### 组件样式 + +使用 `scoped` 避免样式污染: + +```vue + +``` + +#### 命名规范 + +- 使用 BEM 命名法 +- 类名使用 kebab-case + +### 13. 工具函数使用 + +#### request.js (HTTP 请求) + +```javascript +import request from '@/utils/request' + +// GET 请求 +request.get('/api/users', { params: { page: 1 } }) + +// POST 请求 +request.post('/api/users', { name: 'test' }) + +// PUT 请求 +request.put('/api/users/1', { name: 'updated' }) + +// DELETE 请求 +request.delete('/api/users/1') +``` + +#### tool.js (工具函数) + +```javascript +import { formatDate, deepClone } from '@/utils/tool' + +// 格式化日期 +formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss') + +// 深拷贝 +deepClone(originalObject) +``` + +### 14. 开发流程 + +#### 登录流程 + +1. 用户输入用户名和密码 +2. 调用 `authApi.login.post()` 发送登录请求 +3. 后端返回 `token`、`refreshToken`、`userInfo`、`menu`、`permissions` +4. 前端保存数据到 Store(持久化) +5. 路由守卫检测到登录状态,加载动态路由 +6. 跳转到首页或重定向页 + +```javascript +import { useUserStore } from '@/stores/modules/user' +import authApi from '@/api/auth' + +const userStore = useUserStore() + +const handleLogin = async () => { + try { + const res = await authApi.login.post({ + username: 'admin', + password: '123456' + }) + + // 保存 token + userStore.setToken(res.data.token) + userStore.setRefreshToken(res.data.refreshToken) + + // 保存用户信息 + userStore.setUserInfo(res.data.user) + + // 保存菜单(合并静态菜单) + userStore.setMenu(res.data.menu) + + // 保存权限节点 + userStore.setPermissions(res.data.permissions) + + // 跳转首页 + router.push('/dashboard') + } catch (error) { + message.error('登录失败') + } +} +``` + +#### 添加新页面 + +1. 在 `src/pages/` 对应模块下创建页面组件 +2. 在 `src/api/` 中创建对应的 API 文件 +3. 在后端添加对应的菜单和权限配置 +4. 前端会自动加载动态路由(无需手动配置路由) + +#### 添加新组件 + +1. 在 `src/components/` 或对应子目录下创建组件 +2. 遵循组件结构规范 +3. 添加必要的 Props 和 Emits +4. 编写组件文档 + +### 15. 常用命令 + +```bash +# 安装依赖 +npm install + +# 开发环境启动 +npm run dev + +# 生产环境构建 +npm run build + +# 预览生产构建 +npm run preview + +# 代码格式化 +npm run format + +# 代码检查 +npm run lint +``` + +### 16. 注意事项 + +1. **禁止重复引入图标**: Ant Design Vue 图标已全局引入,直接使用即可 +2. **使用组合式 API**: 新代码统一使用 ` +``` + +## 注意事项 + +1. **权限控制**: 日志管理接口需要相应的权限才能访问 +2. **数据安全**: 敏感信息已自动过滤,但仍需注意日志数据的安全存储 +3. **性能影响**: 虽然日志记录不影响响应速度,但大量日志会增加数据库负载 +4. **定期备份**: 重要日志数据建议定期备份 +5. **日志分析**: 可结合 BI 工具对日志数据进行深度分析 + +## 常见问题 + +### Q1: 为什么某些请求没有被记录? + +A: 检查路由是否应用了 `log.request` 中间件,或者在中间件中是否被排除了。 + +### Q2: 日志数据过多怎么办? + +A: 使用 `clearLogs` 方法定期清理历史日志,或设置任务调度器自动清理。 + +### Q3: 如何自定义日志记录规则? + +A: 修改 `LogRequestMiddleware` 中的 `parseModule` 和 `parseAction` 方法。 + +### Q4: 日志记录会影响性能吗? + +A: 日志记录在请求处理后执行,不影响响应速度。但大量日志会增加数据库写入压力。 + +### Q5: 如何查看完整的请求参数? + +A: 在日志详情接口中,`params` 字段包含了完整的请求参数(敏感信息已过滤)。 + +## 更新日志 + +### v1.0.0 (2024-01-01) +- 初始版本 +- 实现基础日志记录功能 +- 支持多维度查询和筛选 +- 支持数据导出 +- 支持批量删除和清理 diff --git a/docs/README_SYSTEM.md b/docs/README_SYSTEM.md new file mode 100644 index 0000000..39344a7 --- /dev/null +++ b/docs/README_SYSTEM.md @@ -0,0 +1,792 @@ +# System 基础模块文档 + +## 概述 + +System 基础模块提供了完整的系统管理功能,包括系统配置、数据字典、操作日志、任务管理、城市数据和文件上传等功能。 + +## 控制器结构 + +``` +app/Http/Controllers/System/ +├── Admin/ # 后台管理控制器 +│ ├── Config.php # 系统配置控制器 +│ ├── Log.php # 操作日志控制器 +│ ├── Dictionary.php # 数据字典控制器 +│ ├── Task.php # 任务管理控制器 +│ ├── City.php # 城市数据控制器 +│ └── Upload.php # 文件上传控制器 +└── Api/ # 公共API控制器 + ├── Config.php # 系统配置API + ├── Dictionary.php # 数据字典API + ├── City.php # 城市数据API + └── Upload.php # 文件上传API +``` + +### Admin 控制器 + +**命名空间**: `App\Http\Controllers\System\Admin` + +**路由前缀**: `/admin` + +**中间件**: `auth.check:admin` (后台管理认证) + +### Api 控制器 + +**命名空间**: `App\Http\Controllers\System\Api` + +**路由前缀**: `/api` + +**中间件**: `auth:api` (API认证,部分接口为公开接口) + +## 技术栈 + +- PHP 8.1+ +- Laravel 11 +- Redis 缓存 +- Intervention Image (图像处理) + +## 数据库表结构 + +### system_configs (系统配置表) +- `id`: 主键 +- `group`: 配置分组(如:system, site, upload) +- `key`: 配置键(唯一) +- `value`: 配置值(JSON格式) +- `type`: 数据类型(string, number, boolean, array, image, file) +- `name`: 配置名称 +- `description`: 配置描述 +- `options`: 可选值(JSON数组) +- `sort`: 排序 +- `status`: 状态(0:禁用, 1:启用) +- `created_at`, `updated_at`: 时间戳 + +### system_dictionaries (数据字典分类表) +- `id`: 主键 +- `name`: 字典名称 +- `code`: 字典编码(唯一) +- `description`: 描述 +- `sort`: 排序 +- `status`: 状态 +- `created_at`, `updated_at`: 时间戳 + +### system_dictionary_items (数据字典项表) +- `id`: 主键 +- `dictionary_id`: 字典ID +- `label`: 显示标签 +- `value`: 实际值 +- `sort`: 排序 +- `status`: 状态 +- `created_at`, `updated_at`: 时间戳 + +### system_logs (操作日志表) +- `id`: 主键 +- `user_id`: 用户ID +- `username`: 用户名 +- `module`: 模块 +- `action`: 操作 +- `method`: 请求方法 +- `url`: 请求URL +- `ip`: IP地址 +- `user_agent`: 用户代理 +- `request_data`: 请求数据(JSON) +- `response_data`: 响应数据(JSON) +- `duration`: 执行时间(毫秒) +- `status_code`: 状态码 +- `created_at`: 创建时间 + +### system_tasks (任务表) +- `id`: 主键 +- `name`: 任务名称 +- `code`: 任务编码(唯一) +- `command`: 命令 +- `description`: 描述 +- `cron_expression`: Cron表达式 +- `status`: 状态(0:禁用, 1:启用, 2:运行中, 3:失败) +- `last_run_at`: 最后运行时间 +- `next_run_at`: 下次运行时间 +- `run_count`: 运行次数 +- `fail_count`: 失败次数 +- `last_message`: 最后运行消息 +- `sort`: 排序 +- `created_at`, `updated_at`: 时间戳 + +### system_cities (城市表) +- `id`: 主键 +- `name`: 城市名称 +- `code`: 城市编码 +- `level`: 级别(1:省, 2:市, 3:区县) +- `parent_id`: 父级ID +- `adcode`: 行政区划编码 +- `sort`: 排序 +- `status`: 状态 +- `created_at`, `updated_at`: 时间戳 + +## Admin API 接口文档 + +### 系统配置管理 + +#### 获取配置列表 +- **接口**: `GET /admin/configs` +- **参数**: + - `page`: 页码(默认1) + - `page_size`: 每页数量(默认20) + - `keyword`: 搜索关键词 + - `group`: 配置分组 + - `status`: 状态 + - `order_by`, `order_direction`: 排序 + +#### 获取所有配置分组 +- **接口**: `GET /admin/configs/groups` + +#### 按分组获取配置 +- **接口**: `GET /admin/configs/all` +- **参数**: + - `group`: 配置分组(可选) + +#### 获取配置详情 +- **接口**: `GET /admin/configs/{id}` + +#### 创建配置 +- **接口**: `POST /admin/configs` +- **参数**: + ```json + { + "group": "site", + "key": "site_name", + "name": "网站名称", + "description": "网站显示的名称", + "type": "string", + "value": "\"我的网站\"", + "options": null, + "sort": 1, + "status": 1 + } + ``` +- **数据类型说明**: + - `string`: 字符串 + - `number`: 数字 + - `boolean`: 布尔值 + - `array`: 数组 + - `image`: 图片 + - `file`: 文件 + +#### 更新配置 +- **接口**: `PUT /admin/configs/{id}` + +#### 删除配置 +- **接口**: `DELETE /admin/configs/{id}` + +#### 批量删除配置 +- **接口**: `POST /admin/configs/batch-delete` +- **参数**: + ```json + { + "ids": [1, 2, 3] + } + ``` + +#### 批量更新配置状态 +- **接口**: `POST /admin/configs/batch-status` +- **参数**: + ```json + { + "ids": [1, 2, 3], + "status": 1 + } + ``` + +### 操作日志管理 + +#### 获取日志列表 +- **接口**: `GET /admin/logs` +- **参数**: + - `page`, `page_size` + - `keyword`: 搜索关键词(用户名/模块/操作) + - `module`: 模块 + - `action`: 操作 + - `user_id`: 用户ID + - `start_date`: 开始日期 + - `end_date`: 结束日期 + - `order_by`, `order_direction` + +#### 获取日志详情 +- **接口**: `GET /admin/logs/{id}` + +#### 删除日志 +- **接口**: `DELETE /admin/logs/{id}` + +#### 批量删除日志 +- **接口**: `POST /admin/logs/batch-delete` +- **参数**: + ```json + { + "ids": [1, 2, 3] + } + ``` + +#### 清理日志 +- **接口**: `POST /admin/logs/clear` +- **参数**: + ```json + { + "days": 30 + } + ``` +- **说明**: 删除指定天数之前的日志记录 + +#### 获取日志统计 +- **接口**: `GET /admin/logs/statistics` +- **参数**: + - `start_date`: 开始日期 + - `end_date`: 结束日期 +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "total_count": 1000, + "module_stats": [ + { + "module": "user", + "count": 500 + } + ], + "user_stats": [ + { + "user_id": 1, + "username": "admin", + "count": 800 + } + ] + } + } + ``` + +### 数据字典管理 + +#### 获取字典列表 +- **接口**: `GET /admin/dictionaries` +- **参数**: + - `page`, `page_size`, `keyword`, `status`, `order_by`, `order_direction` + +#### 获取所有字典 +- **接口**: `GET /admin/dictionaries/all` + +#### 获取字典详情 +- **接口**: `GET /admin/dictionaries/{id}` + +#### 创建字典 +- **接口**: `POST /admin/dictionaries` +- **参数**: + ```json + { + "name": "用户状态", + "code": "user_status", + "description": "用户状态字典", + "sort": 1, + "status": 1 + } + ``` + +#### 更新字典 +- **接口**: `PUT /admin/dictionaries/{id}` + +#### 删除字典 +- **接口**: `DELETE /admin/dictionaries/{id}` + +#### 批量删除字典 +- **接口**: `POST /admin/dictionaries/batch-delete` + +#### 批量更新字典状态 +- **接口**: `POST /admin/dictionaries/batch-status` + +### 数据字典项管理 + +#### 获取字典项列表 +- **接口**: `GET /admin/dictionary-items` +- **参数**: + - `page`, `page_size` + - `dictionary_id`: 字典ID(必需) + - `keyword`: 搜索关键词 + - `status`, `order_by`, `order_direction` + +#### 创建字典项 +- **接口**: `POST /admin/dictionary-items` +- **参数**: + ```json + { + "dictionary_id": 1, + "label": "正常", + "value": "1", + "sort": 1, + "status": 1 + } + ``` + +#### 更新字典项 +- **接口**: `PUT /admin/dictionary-items/{id}` + +#### 删除字典项 +- **接口**: `DELETE /admin/dictionary-items/{id}` + +#### 批量删除字典项 +- **接口**: `POST /admin/dictionary-items/batch-delete` + +#### 批量更新字典项状态 +- **接口**: `POST /admin/dictionary-items/batch-status` + +### 任务管理 + +#### 获取任务列表 +- **接口**: `GET /admin/tasks` +- **参数**: + - `page`, `page_size`, `keyword`, `status`, `order_by`, `order_direction` + +#### 获取所有任务 +- **接口**: `GET /admin/tasks/all` + +#### 获取任务详情 +- **接口**: `GET /admin/tasks/{id}` + +#### 创建任务 +- **接口**: `POST /admin/tasks` +- **参数**: + ```json + { + "name": "清理临时文件", + "code": "cleanup_temp_files", + "command": "cleanup:temp", + "description": "每天清理过期临时文件", + "cron_expression": "0 2 * * *", + "sort": 1, + "status": 1 + } + ``` +- **Cron表达式说明**: + - 格式:`分 时 日 月 周` + - 示例:`0 2 * * *` (每天凌晨2点执行) + - 示例:`*/5 * * * *` (每5分钟执行一次) + +#### 更新任务 +- **接口**: `PUT /admin/tasks/{id}` + +#### 删除任务 +- **接口**: `DELETE /admin/tasks/{id}` + +#### 批量删除任务 +- **接口**: `POST /admin/tasks/batch-delete` + +#### 批量更新任务状态 +- **接口**: `POST /admin/tasks/batch-status` + +#### 手动执行任务 +- **接口**: `POST /admin/tasks/{id}/run` +- **说明**: 立即执行指定任务 + +#### 获取任务统计 +- **接口**: `GET /admin/tasks/statistics` +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "total": 10, + "enabled": 8, + "disabled": 2, + "running": 0, + "failed": 0 + } + } + ``` + +### 城市数据管理 + +#### 获取城市列表 +- **接口**: `GET /admin/cities` +- **参数**: + - `page`, `page_size`, `keyword`, `level`, `parent_id`, `status` + - `order_by`, `order_direction` + +#### 获取城市树 +- **接口**: `GET /admin/cities/tree` +- **说明**: 从缓存中获取完整的三级城市树 + +#### 获取城市详情 +- **接口**: `GET /admin/cities/{id}` + +#### 获取子级城市 +- **接口**: `GET /admin/cities/{id}/children` +- **说明**: 获取指定城市的下级城市 + +#### 获取所有省份 +- **接口**: `GET /admin/cities/provinces` + +#### 获取指定省份的城市 +- **接口**: `GET /admin/cities/{provinceId}/cities` + +#### 获取指定城市的区县 +- **接口**: `GET /admin/cities/{cityId}/districts` + +#### 创建城市 +- **接口**: `POST /admin/cities` +- **参数**: + ```json + { + "name": "北京市", + "code": "110000", + "level": 1, + "parent_id": 0, + "adcode": "110000", + "sort": 1, + "status": 1 + } + ``` +- **级别说明**: + - `1`: 省级 + - `2`: 市级 + - `3`: 区县级 + +#### 更新城市 +- **接口**: `PUT /admin/cities/{id}` + +#### 删除城市 +- **接口**: `DELETE /admin/cities/{id}` + +#### 批量删除城市 +- **接口**: `POST /admin/cities/batch-delete` + +#### 批量更新城市状态 +- **接口**: `POST /admin/cities/batch-status` + +### 文件上传管理 + +#### 单文件上传 +- **接口**: `POST /admin/upload` +- **参数**: + - `file`: 文件(multipart/form-data,最大10MB) + - `directory`: 存储目录(默认:uploads) + - `compress`: 是否压缩图片(默认:false) + - `quality`: 图片质量(1-100,默认:80) + - `width`: 压缩宽度(可选) + - `height`: 压缩高度(可选) +- **返回**: + ```json + { + "code": 200, + "message": "上传成功", + "data": { + "url": "http://example.com/uploads/2024/01/xxx.jpg", + "path": "uploads/2024/01/xxx.jpg", + "name": "xxx.jpg", + "size": 102400, + "mime_type": "image/jpeg" + } + } + ``` + +#### 多文件上传 +- **接口**: `POST /admin/upload/multiple` +- **参数**: + - `files`: 文件数组(multipart/form-data) + - 其他参数同单文件上传 +- **返回**: 文件数组 + +#### Base64上传 +- **接口**: `POST /admin/upload/base64` +- **参数**: + ```json + { + "base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...", + "directory": "uploads", + "file_name": "image.jpg" + } + ``` + +#### 删除文件 +- **接口**: `POST /admin/upload/delete` +- **参数**: + ```json + { + "path": "uploads/2024/01/xxx.jpg" + } + ``` + +#### 批量删除文件 +- **接口**: `POST /admin/upload/batch-delete` +- **参数**: + ```json + { + "paths": [ + "uploads/2024/01/xxx.jpg", + "uploads/2024/01/yyy.jpg" + ] + } + ``` + +## Public API 接口文档 + +### 系统配置 API + +#### 获取所有配置 +- **接口**: `GET /api/system/configs` +- **认证**: 公开接口(无需认证) +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "site_name": "我的网站", + "site_logo": "/uploads/logo.png" + } + } + ``` + +#### 按分组获取配置 +- **接口**: `GET /api/system/configs/group` +- **参数**: + - `group`: 配置分组 +- **认证**: 公开接口 + +#### 根据键获取配置 +- **接口**: `GET /api/system/configs/key` +- **参数**: + - `key`: 配置键 +- **认证**: 公开接口 + +### 数据字典 API + +#### 获取所有字典 +- **接口**: `GET /api/system/dictionaries` +- **认证**: 公开接口 +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": [ + { + "id": 1, + "code": "user_status", + "name": "用户状态", + "items": [...] + } + ] + } + ``` + +#### 根据编码获取字典项 +- **接口**: `GET /api/system/dictionaries/code` +- **参数**: + - `code`: 字典编码 +- **认证**: 公开接口 +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "code": "user_status", + "items": [ + {"label": "正常", "value": "1"}, + {"label": "禁用", "value": "0"} + ] + } + } + ``` + +#### 获取字典详情 +- **接口**: `GET /api/system/dictionaries/{id}` +- **认证**: 公开接口 + +### 城市数据 API + +#### 获取城市树 +- **接口**: `GET /api/system/cities/tree` +- **认证**: 公开接口 +- **说明**: 从缓存中获取完整的三级城市树 + +#### 获取所有省份 +- **接口**: `GET /api/system/cities/provinces` +- **认证**: 公开接口 + +#### 获取指定省份的城市 +- **接口**: `GET /api/system/cities/{provinceId}/cities` +- **认证**: 公开接口 + +#### 获取指定城市的区县 +- **接口**: `GET /api/system/cities/{cityId}/districts` +- **认证**: 公开接口 + +#### 获取城市详情 +- **接口**: `GET /api/system/cities/{id}` +- **认证**: 公开接口 + +### 文件上传 API + +#### 单文件上传 +- **接口**: `POST /api/system/upload` +- **认证**: 需要认证(`auth:api`) +- **参数**: 同Admin上传接口 + +#### 多文件上传 +- **接口**: `POST /api/system/upload/multiple` +- **认证**: 需要认证(`auth:api`) +- **参数**: 同Admin上传接口 + +#### Base64上传 +- **接口**: `POST /api/system/upload/base64` +- **认证**: 需要认证(`auth:api`) +- **参数**: 同Admin上传接口 + +## 缓存机制 + +### 城市数据缓存 + +- **缓存键**: `city:tree` +- **过期时间**: 永久(手动清除) +- **更新时机**: 城市数据增删改时自动清除 + +### 系统配置缓存 + +- **缓存键**: `config:all` 或 `config:group:{group}` +- **过期时间**: 60分钟 +- **更新时机**: 配置数据增删改时自动清除 + +### 数据字典缓存 + +- **缓存键**: `dictionary:all` 或 `dictionary:code:{code}` +- **过期时间**: 60分钟 +- **更新时机**: 字典数据增删改时自动清除 + +## 服务层说明 + +### ConfigService + +提供系统配置的CRUD操作和缓存管理。 + +**主要方法**: +- `getList()`: 获取配置列表 +- `getByGroup()`: 按分组获取配置 +- `getConfigValue()`: 获取配置值 +- `create()`: 创建配置 +- `update()`: 更新配置 +- `delete()`: 删除配置 + +### LogService + +提供操作日志的记录、查询和清理功能。 + +**主要方法**: +- `getList()`: 获取日志列表 +- `getStatistics()`: 获取统计数据 +- `clearLogs()`: 清理过期日志 +- `record()`: 记录日志(由中间件自动调用) + +### DictionaryService + +提供数据字典和字典项的管理功能。 + +**主要方法**: +- `getList()`: 获取字典列表 +- `getItemsByCode()`: 按编码获取字典项 +- `create()`: 创建字典 +- `createItem()`: 创建字典项 +- `update()`: 更新字典 +- `updateItem()`: 更新字典项 + +### TaskService + +提供任务的管理和执行功能。 + +**主要方法**: +- `getList()`: 获取任务列表 +- `run()`: 手动执行任务 +- `getStatistics()`: 获取任务统计 +- `updateStatus()`: 更新任务状态 + +### CityService + +提供城市数据的管理和缓存功能。 + +**主要方法**: +- `getList()`: 获取城市列表 +- `getCachedTree()`: 从缓存获取城市树 +- `getProvinces()`: 获取省份列表 +- `getCities()`: 获取城市列表 +- `getDistricts()`: 获取区县列表 + +### UploadService + +提供文件上传和删除功能。 + +**主要方法**: +- `upload()`: 单文件上传 +- `uploadMultiple()`: 多文件上传 +- `uploadBase64()`: Base64上传 +- `delete()`: 删除文件 +- `compressImage()`: 图片压缩 + +## 初始化数据 + +```bash +# 执行迁移 +php artisan migrate + +# 填充初始数据 +php artisan db:seed --class=SystemSeeder +``` + +初始数据包括: +- 基础系统配置 +- 常用数据字典 +- 全国省市区数据 + +## 注意事项 + +1. **Swoole环境注意事项**: + - 文件上传时注意临时文件清理 + - 使用Redis缓存避免内存泄漏 + - 图片压缩使用协程安全的方式 + +2. **安全注意事项**: + - 文件上传必须验证文件类型和大小 + - 敏感操作必须记录日志 + - 配置数据不要存储密码等敏感信息 + +3. **性能优化**: + - 城市数据使用Redis缓存 + - 大量日志数据定期清理 + - 图片上传时进行压缩处理 + +4. **文件上传**: + - 限制文件上传大小 + - 验证文件MIME类型 + - 定期清理临时文件 + +## 扩展建议 + +1. **日志告警**: 添加日志异常告警功能 +2. **配置加密**: 敏感配置数据加密存储 +3. **多语言**: 支持配置数据的多语言 +4. **任务监控**: 添加任务执行监控和通知 +5. **CDN集成**: 文件上传支持CDN分发 + +## 常见问题 + +### Q: 如何清除城市数据缓存? +A: 调用 `CityService::clearCache()` 方法或运行 `php artisan cache:forget city:tree`。 + +### Q: 图片上传后如何压缩? +A: 上传时设置 `compress=true` 和 `quality` 参数,系统会自动压缩。 + +### Q: 如何配置定时任务? +A: 在Admin后台创建任务,设置Cron表达式,系统会自动调度执行。 + +### Q: 数据字典如何使用? +A: 通过Public API获取字典数据,前端根据数据渲染下拉框等组件。 + +### Q: 日志数据过多如何处理? +A: 定期使用 `/admin/logs/clear` 接口清理过期日志,或在后台设置自动清理任务。 diff --git a/docs/README_WEBSOCKET.md b/docs/README_WEBSOCKET.md new file mode 100644 index 0000000..57f00aa --- /dev/null +++ b/docs/README_WEBSOCKET.md @@ -0,0 +1,684 @@ +# WebSocket 功能文档 + +## 概述 + +本项目基于 Laravel-S 和 Swoole 实现了完整的 WebSocket 功能,支持实时通信、消息推送、广播等功能。 + +## 功能特性 + +- ✅ 实时双向通信 +- ✅ 用户连接管理 +- ✅ 点对点消息发送 +- ✅ 群发消息/广播 +- ✅ 频道订阅/取消订阅 +- ✅ 心跳机制 +- ✅ 自动重连 +- ✅ 在线状态管理 +- ✅ 系统通知推送 +- ✅ 数据更新推送 + +## 架构设计 + +### 后端组件 + +#### 1. WebSocketHandler (`app/Services/WebSocket/WebSocketHandler.php`) + +WebSocket 处理器,实现了 Swoole 的 `WebSocketHandlerInterface` 接口。 + +**主要方法:** +- `onOpen()`: 处理连接建立事件 +- `onMessage()`: 处理消息接收事件 +- `onClose()`: 处理连接关闭事件 + +**支持的消息类型:** +- `ping/pong`: 心跳检测 +- `heartbeat`: 心跳确认 +- `chat`: 私聊消息 +- `broadcast`: 广播消息 +- `subscribe/unsubscribe`: 频道订阅/取消订阅 + +#### 2. WebSocketService (`app/Services/WebSocket/WebSocketService.php`) + +WebSocket 服务类,提供便捷的 WebSocket 操作方法。 + +**主要方法:** +- `sendToUser($userId, $data)`: 发送消息给指定用户 +- `sendToUsers($userIds, $data)`: 发送消息给多个用户 +- `broadcast($data, $excludeUserId)`: 广播消息给所有用户 +- `sendToChannel($channel, $data)`: 发送消息给指定频道 +- `getOnlineUserCount()`: 获取在线用户数 +- `isUserOnline($userId)`: 检查用户是否在线 +- `sendSystemNotification()`: 发送系统通知 +- `pushDataUpdate()`: 推送数据更新 + +#### 3. WebSocketController (`app/Http/Controllers/System/WebSocket.php`) + +WebSocket API 控制器,提供 HTTP 接口用于管理 WebSocket 连接。 + +### 前端组件 + +#### WebSocketClient (`resources/admin/src/utils/websocket.js`) + +WebSocket 客户端封装类。 + +**功能:** +- 自动连接和重连 +- 心跳机制 +- 消息类型路由 +- 事件监听 +- 连接状态管理 + +## 配置说明 + +### Laravel-S 配置 (`config/laravels.php`) + +```php +'websocket' => [ + 'enable' => env('LARAVELS_WEBSOCKET', true), + 'handler' => \App\Services\WebSocket\WebSocketHandler::class, +], + +'swoole_tables' => [ + 'wsTable' => [ + 'size' => 102400, + 'column' => [ + ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], + ['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4], + ], + ], +], +``` + +### 环境变量 + +在 `.env` 文件中添加: + +```env +LARAVELS_WEBSOCKET=true +``` + +## API 接口 + +### 1. 获取在线用户数 + +``` +GET /admin/websocket/online-count +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "online_count": 10 + } +} +``` + +### 2. 获取在线用户列表 + +``` +GET /admin/websocket/online-users +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "user_ids": [1, 2, 3, 4, 5], + "count": 5 + } +} +``` + +### 3. 检查用户在线状态 + +``` +POST /admin/websocket/check-online +``` + +**请求参数:** +```json +{ + "user_id": 1 +} +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "is_online": true + } +} +``` + +### 4. 发送消息给指定用户 + +``` +POST /admin/websocket/send-to-user +``` + +**请求参数:** +```json +{ + "user_id": 1, + "type": "notification", + "data": { + "title": "新消息", + "message": "您有一条新消息" + } +} +``` + +### 5. 发送消息给多个用户 + +``` +POST /admin/websocket/send-to-users +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "type": "notification", + "data": { + "title": "系统通知", + "message": "系统将在今晚进行维护" + } +} +``` + +### 6. 广播消息 + +``` +POST /admin/websocket/broadcast +``` + +**请求参数:** +```json +{ + "type": "notification", + "data": { + "title": "公告", + "message": "欢迎使用新版本" + }, + "exclude_user_id": 1 // 可选:排除某个用户 +} +``` + +### 7. 发送消息到频道 + +``` +POST /admin/websocket/send-to-channel +``` + +**请求参数:** +```json +{ + "channel": "orders", + "type": "data_update", + "data": { + "order_id": 123, + "status": "paid" + } +} +``` + +### 8. 发送系统通知 + +``` +POST /admin/websocket/send-notification +``` + +**请求参数:** +```json +{ + "title": "系统维护", + "message": "系统将于今晚 23:00-24:00 进行维护", + "type": "warning", + "extra_data": { + "start_time": "23:00", + "end_time": "24:00" + } +} +``` + +### 9. 发送通知给指定用户 + +``` +POST /admin/websocket/send-notification-to-users +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "title": "订单更新", + "message": "您的订单已发货", + "type": "success" +} +``` + +### 10. 推送数据更新 + +``` +POST /admin/websocket/push-data-update +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "resource_type": "order", + "action": "update", + "data": { + "id": 123, + "status": "shipped" + } +} +``` + +### 11. 推送数据更新到频道 + +``` +POST /admin/websocket/push-data-update-channel +``` + +**请求参数:** +```json +{ + "channel": "orders", + "resource_type": "order", + "action": "create", + "data": { + "id": 124, + "customer": "张三", + "amount": 100.00 + } +} +``` + +### 12. 断开用户连接 + +``` +POST /admin/websocket/disconnect-user +``` + +**请求参数:** +```json +{ + "user_id": 1 +} +``` + +## 前端使用示例 + +### 1. 基本连接 + +```javascript +import { getWebSocket, closeWebSocket } from '@/utils/websocket' +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +// 连接 WebSocket +const ws = getWebSocket(userStore.userInfo.id, userStore.token, { + onOpen: (event) => { + console.log('WebSocket 已连接') + }, + onMessage: (message) => { + console.log('收到消息:', message) + }, + onError: (error) => { + console.error('WebSocket 错误:', error) + }, + onClose: (event) => { + console.log('WebSocket 已关闭') + } +}) + +// 连接 +ws.connect() +``` + +### 2. 监听特定消息类型 + +```javascript +// 监听通知消息 +ws.on('notification', (data) => { + message.success(data.title, data.message) +}) + +// 监听数据更新 +ws.on('data_update', (data) => { + console.log('数据更新:', data.resource_type, data.action) + // 刷新数据 + loadData() +}) +``` + +### 3. 发送消息 + +```javascript +// 发送心跳 +ws.send('heartbeat', { timestamp: Date.now() }) + +// 发送私聊消息 +ws.send('chat', { + to_user_id: 2, + content: '你好,这是一条私聊消息' +}) + +// 订阅频道 +ws.send('subscribe', { channel: 'orders' }) + +// 取消订阅 +ws.send('unsubscribe', { channel: 'orders' }) +``` + +### 4. 发送广播消息 + +```javascript +ws.send('broadcast', { + message: '这是一条广播消息' +}) +``` + +### 5. 断开连接 + +```javascript +// 断开连接 +ws.disconnect() + +// 或使用全局方法 +closeWebSocket() +``` + +### 6. 在 Vue 组件中使用 + +```vue + + + +``` + +## 消息格式 + +### 服务端发送的消息格式 + +```json +{ + "type": "notification", + "data": { + "title": "标题", + "message": "内容", + "type": "info", + "timestamp": 1641234567 + } +} +``` + +### 客户端发送的消息格式 + +```json +{ + "type": "chat", + "data": { + "to_user_id": 2, + "content": "消息内容" + } +} +``` + +## 启动和停止 + +### 启动 Laravel-S 服务 + +```bash +php bin/laravels start +``` + +### 停止 Laravel-S 服务 + +```bash +php bin/laravels stop +``` + +### 重启 Laravel-S 服务 + +```bash +php bin/laravels restart +``` + +### 重载 Laravel-S 服务(平滑重启) + +```bash +php bin/laravels reload +``` + +### 查看服务状态 + +```bash +php bin/laravels status +``` + +## WebSocket 连接地址 + +### 开发环境 + +``` +ws://localhost:5200/ws?user_id={user_id}&token={token} +``` + +### 生产环境 + +``` +wss://yourdomain.com/ws?user_id={user_id}&token={token} +``` + +## Nginx 配置示例 + +```nginx +server { + listen 80; + server_name yourdomain.com; + root /path/to/your/project/public; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # WebSocket 代理配置 + location /ws { + proxy_pass http://127.0.0.1:5200; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } +} +``` + +## 使用场景 + +### 1. 实时通知 + +```php +// 发送系统通知 +$webSocketService->sendSystemNotification( + '系统维护', + '系统将于今晚进行维护', + 'warning' +); +``` + +### 2. 订单状态更新 + +```php +// 推送订单状态更新给相关人员 +$webSocketService->pushDataUpdate( + [$order->user_id], + 'order', + 'update', + [ + 'id' => $order->id, + 'status' => $order->status, + 'updated_at' => $order->updated_at + ] +); +``` + +### 3. 实时聊天 + +```javascript +// 发送私聊消息 +ws.send('chat', { + to_user_id: 2, + content: '你好' +}) +``` + +### 4. 数据监控 + +```php +// 推送系统监控数据到特定频道 +$webSocketService->sendToChannel('system_monitor', 'monitor', [ + 'cpu_usage' => 75, + 'memory_usage' => 80, + 'disk_usage' => 60 +]); +``` + +## 注意事项 + +1. **连接认证**: WebSocket 连接时需要提供 `user_id` 和 `token` 参数 +2. **心跳机制**: 客户端默认每 30 秒发送一次心跳 +3. **自动重连**: 连接断开后会自动尝试重连,最多重试 5 次 +4. **并发限制**: Swoole Table 最多支持 102,400 个连接 +5. **内存管理**: 注意内存泄漏问题,定期重启服务 +6. **安全性**: 生产环境建议使用 WSS (WebSocket Secure) +7. **日志监控**: 查看日志文件 `storage/logs/swoole-YYYY-MM.log` + +## 故障排查 + +### 1. 无法连接 WebSocket + +- 检查 Laravel-S 服务是否启动 +- 检查端口 5200 是否被占用 +- 检查防火墙设置 +- 查看日志文件 + +### 2. 连接频繁断开 + +- 检查网络稳定性 +- 调整心跳间隔 +- 检查服务器资源使用情况 + +### 3. 消息发送失败 + +- 检查用户是否在线 +- 检查消息格式是否正确 +- 查看错误日志 + +## 参考资料 + +- [Laravel-S 文档](https://github.com/hhxsv5/laravel-s) +- [Swoole 文档](https://www.swoole.com/) +- [WebSocket API](https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket) + +## 更新日志 + +### 2024-02-08 + +- ✅ 初始版本发布 +- ✅ 实现基础 WebSocket 功能 +- ✅ 实现消息推送功能 +- ✅ 实现频道订阅功能 +- ✅ 实现前端客户端封装 +- ✅ 实现管理 API 接口 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c96c632 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "laravel-vite-plugin": "^1.0", + "vite": "^5.0" + }, + "dependencies": { + "axios": "^1.7.9", + "vue": "^3.5.13", + "vuex": "^4.1.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d703241 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/admin/.gitignore b/resources/admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/resources/admin/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/resources/admin/.vscode/extensions.json b/resources/admin/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/resources/admin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/resources/admin/README.md b/resources/admin/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/resources/admin/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + + + diff --git a/resources/admin/package.json b/resources/admin/package.json new file mode 100644 index 0000000..3a86e1a --- /dev/null +++ b/resources/admin/package.json @@ -0,0 +1,43 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --fix --cache", + "format": "prettier --write --experimental-cli src/" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@ckeditor/ckeditor5-vue": "^7.3.0", + "@element-plus/icons-vue": "^2.3.2", + "ant-design-vue": "^4.2.6", + "axios": "^1.13.4", + "ckeditor5": "^47.4.0", + "crypto-js": "^4.2.0", + "echarts": "^6.0.0", + "nprogress": "^0.2.0", + "pinia": "^3.0.4", + "pinia-plugin-persistedstate": "^4.7.1", + "vue": "^3.5.24", + "vue-i18n": "^11.2.8", + "vue-router": "^5.0.2", + "vuedraggable": "^4.0.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "eslint": "^10.0.0", + "eslint-plugin-vue": "^10.7.0", + "globals": "^17.3.0", + "prettier": "^3.8.1", + "sass-embedded": "^1.97.3", + "vite": "^7.2.4", + "vite-plugin-vue-devtools": "^8.0.6", + "vue-eslint-parser": "^10.2.0" + } +} diff --git a/resources/admin/public/vite.svg b/resources/admin/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/resources/admin/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/admin/src/App.vue b/resources/admin/src/App.vue new file mode 100644 index 0000000..71be399 --- /dev/null +++ b/resources/admin/src/App.vue @@ -0,0 +1,92 @@ + + + diff --git a/resources/admin/src/api/auth.js b/resources/admin/src/api/auth.js new file mode 100644 index 0000000..ad2c613 --- /dev/null +++ b/resources/admin/src/api/auth.js @@ -0,0 +1,302 @@ +import request from '@/utils/request' + +export default { + // 认证相关 + login: { + post: async function (params) { + return await request.post('auth/login', params) + }, + }, + logout: { + post: async function () { + return await request.post('auth/logout') + }, + }, + refresh: { + post: async function () { + return await request.post('auth/refresh') + }, + }, + me: { + get: async function () { + return await request.get('auth/me') + }, + }, + changePassword: { + post: async function (params) { + return await request.post('auth/change-password', params) + }, + }, + + // 用户管理 + users: { + list: { + get: async function (params) { + return await request.get('users', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`users/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('users', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`users/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`users/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('users/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('users/batch-status', params) + }, + }, + batchDepartment: { + post: async function (params) { + return await request.post('users/batch-department', params) + }, + }, + batchRoles: { + post: async function (params) { + return await request.post('users/batch-roles', params) + }, + }, + export: { + post: async function (params) { + return await request.post('users/export', params, { responseType: 'blob' }) + }, + }, + import: { + post: async function (formData) { + return await request.post('users/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + downloadTemplate: { + get: async function () { + return await request.get('users/download-template', { responseType: 'blob' }) + }, + }, + }, + + // 在线用户管理 + onlineUsers: { + count: { + get: async function () { + return await request.get('online-users/count') + }, + }, + list: { + get: async function (params) { + return await request.get('online-users', { params }) + }, + }, + sessions: { + get: async function (userId) { + return await request.get(`online-users/${userId}/sessions`) + }, + }, + offline: { + post: async function (userId, params) { + return await request.post(`online-users/${userId}/offline`, params) + }, + }, + offlineAll: { + post: async function (userId) { + return await request.post(`online-users/${userId}/offline-all`) + }, + }, + }, + + // 角色管理 + roles: { + list: { + get: async function (params) { + return await request.get('roles', { params }) + }, + }, + all: { + get: async function () { + return await request.get('roles/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`roles/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('roles', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`roles/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`roles/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('roles/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('roles/batch-status', params) + }, + }, + permissions: { + get: async function (id) { + return await request.get(`roles/${id}/permissions`) + }, + post: async function (id, params) { + return await request.post(`roles/${id}/permissions`, params) + }, + }, + copy: { + post: async function (id, params) { + return await request.post(`roles/${id}/copy`, params) + }, + }, + batchCopy: { + post: async function (params) { + return await request.post('roles/batch-copy', params) + }, + }, + }, + + // 权限管理 + permissions: { + list: { + get: async function (params) { + return await request.get('permissions', { params }) + }, + }, + tree: { + get: async function () { + return await request.get('permissions/tree') + }, + }, + menu: { + get: async function () { + return await request.get('permissions/menu') + }, + }, + detail: { + get: async function (id) { + return await request.get(`permissions/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('permissions', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`permissions/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`permissions/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('permissions/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('permissions/batch-status', params) + }, + }, + }, + + // 部门管理 + departments: { + list: { + get: async function (params) { + return await request.get('departments', { params }) + }, + }, + tree: { + get: async function () { + return await request.get('departments/tree') + }, + }, + all: { + get: async function () { + return await request.get('departments/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`departments/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('departments', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`departments/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`departments/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('departments/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('departments/batch-status', params) + }, + }, + export: { + post: async function (params) { + return await request.post('departments/export', params, { responseType: 'blob' }) + }, + }, + import: { + post: async function (formData) { + return await request.post('departments/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + downloadTemplate: { + get: async function () { + return await request.get('departments/download-template', { responseType: 'blob' }) + }, + }, + }, +} diff --git a/resources/admin/src/api/system.js b/resources/admin/src/api/system.js new file mode 100644 index 0000000..c7c6beb --- /dev/null +++ b/resources/admin/src/api/system.js @@ -0,0 +1,392 @@ +import request from '@/utils/request' + +export default { + // 系统配置管理 + configs: { + list: { + get: async function (params) { + return await request.get('configs', { params }) + }, + }, + groups: { + get: async function () { + return await request.get('configs/groups') + }, + }, + all: { + get: async function (params) { + return await request.get('configs/all', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`configs/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('configs', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`configs/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`configs/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('configs/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('configs/batch-status', params) + }, + }, + }, + + // 操作日志管理 + logs: { + list: { + get: async function (params) { + return await request.get('logs', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`logs/${id}`) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`logs/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('logs/batch-delete', params) + }, + }, + clear: { + post: async function (params) { + return await request.post('logs/clear', params) + }, + }, + statistics: { + get: async function (params) { + return await request.get('logs/statistics', { params }) + }, + }, + }, + + // 数据字典管理 + dictionaries: { + list: { + get: async function (params) { + return await request.get('dictionaries', { params }) + }, + }, + all: { + get: async function () { + return await request.get('dictionaries/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`dictionaries/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('dictionaries', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`dictionaries/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`dictionaries/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('dictionaries/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('dictionaries/batch-status', params) + }, + }, + }, + + // 数据字典项管理 + dictionaryItems: { + list: { + get: async function (params) { + return await request.get('dictionary-items', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`dictionary-items/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('dictionary-items', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`dictionary-items/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`dictionary-items/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('dictionary-items/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('dictionary-items/batch-status', params) + }, + }, + }, + + // 任务管理 + tasks: { + list: { + get: async function (params) { + return await request.get('tasks', { params }) + }, + }, + all: { + get: async function () { + return await request.get('tasks/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`tasks/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('tasks', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`tasks/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`tasks/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('tasks/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('tasks/batch-status', params) + }, + }, + run: { + post: async function (id) { + return await request.post(`tasks/${id}/run`) + }, + }, + statistics: { + get: async function () { + return await request.get('tasks/statistics') + }, + }, + }, + + // 城市数据管理 + cities: { + list: { + get: async function (params) { + return await request.get('cities', { params }) + }, + }, + tree: { + get: async function () { + return await request.get('cities/tree') + }, + }, + detail: { + get: async function (id) { + return await request.get(`cities/${id}`) + }, + }, + children: { + get: async function (id) { + return await request.get(`cities/${id}/children`) + }, + }, + provinces: { + get: async function () { + return await request.get('cities/provinces') + }, + }, + cities: { + get: async function (provinceId) { + return await request.get(`cities/${provinceId}/cities`) + }, + }, + districts: { + get: async function (cityId) { + return await request.get(`cities/${cityId}/districts`) + }, + }, + add: { + post: async function (params) { + return await request.post('cities', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`cities/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`cities/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('cities/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('cities/batch-status', params) + }, + }, + }, + + // 文件上传管理 + upload: { + single: { + post: async function (formData) { + return await request.post('upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + multiple: { + post: async function (formData) { + return await request.post('upload/multiple', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + base64: { + post: async function (params) { + return await request.post('upload/base64', params) + }, + }, + delete: { + post: async function (params) { + return await request.post('upload/delete', params) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('upload/batch-delete', params) + }, + }, + }, + + // 公共接口 (无需认证) + public: { + configs: { + all: { + get: async function () { + return await request.get('system/configs') + }, + }, + group: { + get: async function (params) { + return await request.get('system/configs/group', { params }) + }, + }, + key: { + get: async function (params) { + return await request.get('system/configs/key', { params }) + }, + }, + }, + dictionaries: { + all: { + get: async function () { + return await request.get('system/dictionaries') + }, + }, + code: { + get: async function (params) { + return await request.get('system/dictionaries/code', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`system/dictionaries/${id}`) + }, + }, + }, + cities: { + tree: { + get: async function () { + return await request.get('system/cities/tree') + }, + }, + provinces: { + get: async function () { + return await request.get('system/cities/provinces') + }, + }, + cities: { + get: async function (provinceId) { + return await request.get(`system/cities/${provinceId}/cities`) + }, + }, + districts: { + get: async function (cityId) { + return await request.get(`system/cities/${cityId}/districts`) + }, + }, + detail: { + get: async function (id) { + return await request.get(`system/cities/${id}`) + }, + }, + }, + upload: { + post: async function (formData) { + return await request.post('system/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + }, +} diff --git a/resources/admin/src/assets/images/default_avatar.jpg b/resources/admin/src/assets/images/default_avatar.jpg new file mode 100644 index 0000000..eae583b Binary files /dev/null and b/resources/admin/src/assets/images/default_avatar.jpg differ diff --git a/resources/admin/src/assets/images/logo.png b/resources/admin/src/assets/images/logo.png new file mode 100644 index 0000000..bfcd8f7 Binary files /dev/null and b/resources/admin/src/assets/images/logo.png differ diff --git a/resources/admin/src/assets/style/app.scss b/resources/admin/src/assets/style/app.scss new file mode 100644 index 0000000..3b453ad --- /dev/null +++ b/resources/admin/src/assets/style/app.scss @@ -0,0 +1,164 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +// ==================== 全局滚动条样式优化 ==================== +// Webkit 滚动条基础样式 +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +// 滚动条轨道 +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; + margin: 4px; +} + +// 滚动条滑块 - 渐变色设计 +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #d9d9d9 0%, #bfbfbf 100%); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + transition: all 0.3s ease; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + + &:hover { + background: linear-gradient(180deg, #c0c0c0 0%, #a6a6a6 100%); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &:active { + background: linear-gradient(180deg, #a6a6a6 0%, #8c8c8c 100%); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + } +} + +// 滚动条两端按钮 +::-webkit-scrollbar-button { + display: none; +} + +// 滚动条角落 +::-webkit-scrollbar-corner { + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; +} + +// Firefox 滚动条样式 +* { + scrollbar-width: thin; + scrollbar-color: #d4d4d4 rgba(0, 0, 0, 0.03); +} + +#app { + min-height: 100vh; +} + +.pages { + flex: 1; + display: flex; + flex-direction: column; + background-color: #ffffff; + + .tool-bar { + padding: 12px 16px; + background-color: #fff; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + .left-panel { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + overflow-x: auto; + + :deep(.ant-form) { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 1; + } + + :deep(.ant-form-item) { + margin-bottom: 0; + } + + :deep(.ant-form-item-label) { + min-width: 70px; + } + } + + .right-panel { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + // 按钮组样式 + .button-group { + display: flex; + gap: 8px; + } + + // 搜索输入框样式 + :deep(.ant-input), + :deep(.ant-select-selector) { + border-radius: 4px; + } + + // 按钮样式优化 + :deep(.ant-btn) { + border-radius: 4px; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + + &:active { + transform: translateY(0); + } + } + + // 主按钮特殊样式 + :deep(.ant-btn-primary) { + background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); + border: none; + + &:hover { + background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%); + } + } + + // 危险按钮样式 + :deep(.ant-btn-dangerous) { + &:hover { + background: #ff4d4f; + border-color: #ff4d4f; + color: #fff; + } + } + } +} diff --git a/resources/admin/src/assets/style/auth-pages.scss b/resources/admin/src/assets/style/auth-pages.scss new file mode 100644 index 0000000..b07d2ff --- /dev/null +++ b/resources/admin/src/assets/style/auth-pages.scss @@ -0,0 +1,570 @@ +// 认证页面统一样式文件 +// 使用明亮暖色调配色方案 + +// ===== 颜色变量 ===== +$primary-color: #ff6b35; // 橙红色 +$primary-light: #ff8a5b; // 浅橙红色 +$primary-dark: #e55a2b; // 深橙红色 +$secondary-color: #ffd93d; // 金黄色 +$accent-color: #ffb84d; // 橙黄色 + +$bg-dark: #1a1a2e; // 深色背景 +$bg-light: #16213e; // 浅色背景 +$bg-gradient-start: #0f0f23; // 渐变开始 +$bg-gradient-end: #1a1a2e; // 渐变结束 + +$text-primary: #ffffff; +$text-secondary: rgba(255, 255, 255, 0.7); +$text-muted: rgba(255, 255, 255, 0.5); + +$border-color: rgba(255, 255, 255, 0.08); +$border-hover: rgba(255, 107, 53, 0.3); +$border-focus: rgba(255, 107, 53, 0.6); + +// ===== 基础容器 ===== +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + position: relative; + background: linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-end 100%); + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +// ===== 科技感背景 ===== +.tech-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + + // 网格线 + .grid-line { + position: absolute; + width: 100%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 107, 53, 0.08), transparent); + animation: gridMove 8s linear infinite; + + &:nth-child(1) { top: 20%; animation-delay: 0s; } + &:nth-child(2) { top: 40%; animation-delay: 2s; } + &:nth-child(3) { top: 60%; animation-delay: 4s; } + &:nth-child(4) { top: 80%; animation-delay: 6s; } + } + + // 光点效果 + .light-spot { + position: absolute; + width: 4px; + height: 4px; + background: $primary-color; + border-radius: 50%; + box-shadow: 0 0 10px $primary-color, 0 0 20px $primary-color; + animation: float 6s ease-in-out infinite; + + &:nth-child(5) { top: 15%; left: 20%; animation-delay: 0s; } + &:nth-child(6) { top: 25%; left: 70%; animation-delay: 2s; } + &:nth-child(7) { top: 55%; left: 15%; animation-delay: 4s; } + &:nth-child(8) { top: 75%; left: 80%; animation-delay: 1s; } + } +} + +@keyframes gridMove { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +@keyframes float { + 0%, 100% { transform: translateY(0) scale(1); opacity: 0.6; } + 50% { transform: translateY(-20px) scale(1.2); opacity: 1; } +} + +// ===== 主卡片 ===== +.auth-wrapper { + width: 100%; + max-width: 960px; + padding: 20px; + position: relative; + z-index: 1; +} + +.auth-card { + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-radius: 28px; + padding: 0; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border: 1px solid $border-color; + overflow: hidden; + display: flex; + min-height: 580px; + animation: cardFadeIn 0.6s ease-out; +} + +@keyframes cardFadeIn { + 0% { opacity: 0; transform: translateY(20px); } + 100% { opacity: 1; transform: translateY(0); } +} + +// ===== 左侧装饰区 ===== +.decoration-area { + flex: 1; + background: linear-gradient(135deg, rgba(255, 107, 53, 0.08) 0%, rgba(255, 217, 61, 0.03) 100%); + padding: 60px 40px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + border-right: 1px solid $border-color; +} + +.tech-circle { + position: relative; + width: 220px; + height: 220px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48px; + + .circle-inner { + width: 110px; + height: 110px; + background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%); + border-radius: 50%; + box-shadow: 0 0 50px rgba(255, 107, 53, 0.4); + animation: pulse 3s ease-in-out infinite; + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: ''; + width: 60px; + height: 60px; + background: linear-gradient(135deg, $secondary-color 0%, $accent-color 100%); + border-radius: 50%; + box-shadow: 0 0 30px rgba(255, 217, 61, 0.5); + } + } + + .circle-ring { + position: absolute; + width: 160px; + height: 160px; + border: 2px solid rgba(255, 107, 53, 0.2); + border-radius: 50%; + animation: rotate 12s linear infinite; + + &::before { + content: ''; + position: absolute; + top: -2px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 8px; + background: $primary-color; + border-radius: 50%; + box-shadow: 0 0 15px $primary-color; + } + } + + .circle-ring-2 { + width: 200px; + height: 200px; + border: 1px solid rgba(255, 217, 61, 0.15); + animation: rotate 18s linear infinite reverse; + } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.08); opacity: 0.85; } +} + +@keyframes rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.decoration-text { + text-align: center; + + h2 { + margin: 0 0 16px; + font-size: 32px; + font-weight: 700; + background: linear-gradient(135deg, $primary-color 0%, $secondary-color 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 1px; + } + + p { + margin: 0; + color: $text-secondary; + font-size: 16px; + font-weight: 400; + letter-spacing: 0.5px; + } +} + +// ===== 右侧表单区 ===== +.form-area { + flex: 1.3; + padding: 60px 56px; +} + +.auth-header { + margin-bottom: 40px; + + h1 { + margin: 0 0 12px; + font-size: 36px; + font-weight: 700; + background: linear-gradient(135deg, $primary-color 0%, $accent-color 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 1px; + } + + .subtitle { + margin: 0; + color: $text-secondary; + font-size: 15px; + font-weight: 400; + } +} + +// ===== 表单样式 ===== +.auth-form { + margin-top: 0; + + :deep(.ant-form-item) { + margin-bottom: 26px; + } + + :deep(.ant-form-item-label > label) { + color: $text-secondary; + font-size: 14px; + font-weight: 500; + } + + // 输入框样式 + :deep(.ant-input-affix-wrapper), + :deep(.ant-input) { + background: rgba(255, 255, 255, 0.03); + border: 1px solid $border-color; + border-radius: 12px; + color: $text-primary; + padding: 12px 16px; + font-size: 15px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: rgba(255, 255, 255, 0.06); + border-color: $border-hover; + } + + &:focus, + &.ant-input-affix-wrapper-focused { + background: rgba(255, 255, 255, 0.06); + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); + } + } + + :deep(.ant-input::placeholder) { + color: $text-muted; + } + + :deep(.ant-input-affix-wrapper > input.ant-input) { + background: transparent; + } + + // 图标样式 + :deep(.anticon) { + color: $text-secondary; + font-size: 16px; + transition: color 0.3s; + } + + :deep(.ant-input-affix-wrapper-focused .anticon) { + color: $primary-color; + } +} + +// ===== 按钮样式 ===== +.auth-form :deep(.ant-btn-primary) { + background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%); + border: none; + border-radius: 12px; + height: 48px; + font-weight: 600; + font-size: 16px; + letter-spacing: 0.5px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%); + box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4); + transform: translateY(-2px); + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(255, 107, 53, 0.3); + } + + &:disabled { + background: rgba(255, 255, 255, 0.08); + color: $text-muted; + box-shadow: none; + transform: none; + cursor: not-allowed; + } +} + +// ===== 表单选项 ===== +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 26px; + + :deep(.ant-checkbox-wrapper) { + color: $text-primary; + font-size: 14px; + + .ant-checkbox { + .ant-checkbox-inner { + border-color: $border-color; + background: rgba(255, 255, 255, 0.03); + } + + &.ant-checkbox-checked .ant-checkbox-inner { + background: $primary-color; + border-color: $primary-color; + } + } + } +} + +.forgot-password { + color: $primary-color; + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.3s; + + &:hover { + color: $primary-light; + text-decoration: underline; + } +} + +// ===== 验证码输入框 ===== +.code-input-wrapper { + display: flex; + gap: 14px; + + .code-input { + flex: 1; + } + + .code-btn { + width: 150px; + white-space: nowrap; + background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%); + border: none; + border-radius: 12px; + font-weight: 600; + font-size: 14px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%); + box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4); + transform: translateY(-2px); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + background: rgba(255, 255, 255, 0.08); + color: $text-muted; + box-shadow: none; + transform: none; + cursor: not-allowed; + } + } +} + +// ===== 协议复选框 ===== +.agreement-checkbox { + :deep(.ant-checkbox-wrapper) { + color: $text-secondary; + font-size: 13px; + align-items: flex-start; + line-height: 1.6; + + .ant-checkbox { + margin-top: 2px; + + .ant-checkbox-inner { + border-color: $border-color; + background: rgba(255, 255, 255, 0.03); + } + + &.ant-checkbox-checked .ant-checkbox-inner { + background: $primary-color; + border-color: $primary-color; + } + } + } +} + +.agreement-text { + font-size: 13px; + line-height: 1.6; + color: $text-secondary; +} + +.link { + color: $primary-color; + text-decoration: none; + font-weight: 500; + transition: all 0.3s; + + &:hover { + color: $primary-light; + text-decoration: underline; + } +} + +// ===== 表单底部 ===== +.form-footer { + text-align: center; + margin-top: 28px; + color: $text-secondary; + font-size: 14px; + + .auth-link { + color: $primary-color; + text-decoration: none; + font-weight: 600; + margin-left: 6px; + transition: all 0.3s; + + &:hover { + color: $primary-light; + text-decoration: underline; + } + } +} + +// ===== 响应式设计 ===== +@media (max-width: 768px) { + .auth-card { + flex-direction: column; + min-height: auto; + margin: 20px 0; + } + + .decoration-area { + padding: 48px 24px; + border-right: none; + border-bottom: 1px solid $border-color; + } + + .tech-circle { + width: 160px; + height: 160px; + + .circle-inner { + width: 80px; + height: 80px; + + &::after { + width: 45px; + height: 45px; + } + } + + .circle-ring { + width: 120px; + height: 120px; + } + + .circle-ring-2 { + width: 150px; + height: 150px; + } + } + + .decoration-text { + h2 { + font-size: 26px; + } + + p { + font-size: 14px; + } + } + + .form-area { + padding: 48px 32px; + } + + .auth-header { + h1 { + font-size: 28px; + } + + .subtitle { + font-size: 14px; + } + } + + .code-input-wrapper { + flex-direction: column; + gap: 12px; + + .code-btn { + width: 100%; + } + } +} + +@media (max-width: 480px) { + .auth-wrapper { + padding: 16px; + } + + .auth-card { + border-radius: 20px; + } + + .form-area { + padding: 36px 24px; + } + + .auth-header { + margin-bottom: 32px; + } +} diff --git a/resources/admin/src/assets/style/auth.scss b/resources/admin/src/assets/style/auth.scss new file mode 100644 index 0000000..f594d23 --- /dev/null +++ b/resources/admin/src/assets/style/auth.scss @@ -0,0 +1,356 @@ +// Auth Pages - Warm Tech Theme +// Warm color palette with tech-inspired design + +:root { + --auth-primary: #ff6b35; + --auth-primary-light: #ff8c5a; + --auth-primary-dark: #e55a2b; + --auth-secondary: #ffb347; + --accent-orange: #ffa500; + --accent-coral: #ff7f50; + --accent-amber: #ffc107; + + --bg-gradient-start: #fff5f0; + --bg-gradient-end: #ffe8dc; + --card-bg: rgba(255, 255, 255, 0.95); + + --text-primary: #2d1810; + --text-secondary: #6b4423; + --text-muted: #a67c52; + + --border-color: #ffd4b8; + --shadow-color: rgba(255, 107, 53, 0.15); + + --success: #28a745; + --warning: #ffc107; + --error: #dc3545; + + --tech-blue: #007bff; + --tech-purple: #6f42c1; +} + +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + position: relative; + overflow: hidden; + + // Tech pattern background + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 20% 50%, rgba(255, 107, 53, 0.03) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.05) 0%, transparent 40%), + radial-gradient(circle at 40% 80%, rgba(255, 127, 80, 0.04) 0%, transparent 40%); + pointer-events: none; + } + + // Animated tech elements + &::after { + content: ''; + position: absolute; + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%); + border-radius: 50%; + top: -200px; + right: -200px; + animation: float 20s ease-in-out infinite; + pointer-events: none; + } +} + +@keyframes float { + 0%, + 100% { + transform: translate(0, 0); + } + 50% { + transform: translate(-50px, 50px); + } +} + +.auth-card { + width: 100%; + max-width: 440px; + background: var(--card-bg); + backdrop-filter: blur(20px); + border-radius: 24px; + padding: 48px 40px; + box-shadow: + 0 20px 60px var(--shadow-color), + 0 8px 24px rgba(0, 0, 0, 0.08); + position: relative; + z-index: 1; + margin: 20px; + + // Tech accent line + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 80px; + height: 4px; + background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary)); + border-radius: 0 0 4px 4px; + } +} + +.auth-header { + text-align: center; + margin-bottom: 40px; + + .auth-title { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .auth-subtitle { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } +} + +.auth-form { + .ant-form-item { + margin-bottom: 24px; + } + + .ant-input { + border-radius: 12px; + border: 1px solid var(--border-color); + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08); + + &:hover { + border-color: var(--auth-primary-light); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + + &:focus, + &.ant-input-focused { + border-color: var(--auth-primary); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + } + + .ant-input-affix-wrapper { + border-radius: 12px; + border: 1px solid var(--border-color); + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08); + + &:hover { + border-color: var(--auth-primary-light); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + + &:focus, + &.ant-input-affix-wrapper-focused { + border-color: var(--auth-primary); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + + .ant-input { + border: none; + box-shadow: none; + } + } + + .ant-input-prefix { + color: var(--auth-primary); + font-size: 18px; + margin-right: 8px; + } + + .ant-input-suffix { + color: var(--text-muted); + } + + .ant-btn { + height: 48px; + font-size: 16px; + font-weight: 600; + border-radius: 12px; + transition: all 0.3s ease; + + &.ant-btn-primary { + background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark)); + border: none; + box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35); + + &:hover { + background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary)); + transform: translateY(-2px); + box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45); + } + + &:active { + transform: translateY(0); + } + } + } +} + +.auth-links { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + .remember-me { + .ant-checkbox-inner { + border-radius: 4px; + border-color: var(--border-color); + } + + .ant-checkbox-wrapper { + color: var(--text-secondary); + font-size: 14px; + } + + &.ant-checkbox-wrapper-checked { + .ant-checkbox-inner { + background-color: var(--auth-primary); + border-color: var(--auth-primary); + } + } + } + + .forgot-password { + color: var(--auth-primary); + font-size: 14px; + text-decoration: none; + transition: color 0.3s ease; + + &:hover { + color: var(--auth-primary-dark); + } + } +} + +.auth-divider { + display: flex; + align-items: center; + margin: 32px 0; + color: var(--text-muted); + font-size: 13px; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-color); + } + + span { + padding: 0 16px; + } +} + +.auth-footer { + text-align: center; + margin-top: 24px; + + .auth-footer-text { + color: var(--text-secondary); + font-size: 14px; + + .auth-link { + color: var(--auth-primary); + text-decoration: none; + font-weight: 600; + margin-left: 4px; + transition: color 0.3s ease; + + &:hover { + color: var(--auth-primary-dark); + } + } + } +} + +.tech-decoration { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + pointer-events: none; + overflow: hidden; + + .tech-circle { + position: absolute; + border: 2px solid rgba(255, 107, 53, 0.1); + border-radius: 50%; + animation: pulse 4s ease-in-out infinite; + } + + .tech-circle:nth-child(1) { + width: 300px; + height: 300px; + top: -150px; + left: -150px; + animation-delay: 0s; + } + + .tech-circle:nth-child(2) { + width: 200px; + height: 200px; + bottom: -100px; + right: -100px; + animation-delay: 1s; + } + + .tech-circle:nth-child(3) { + width: 150px; + height: 150px; + bottom: 20%; + left: -75px; + animation-delay: 2s; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.05); + } +} + +// Responsive design +@media (max-width: 768px) { + .auth-card { + padding: 40px 24px; + margin: 16px; + } + + .auth-header { + .auth-title { + font-size: 24px; + } + } +} diff --git a/resources/admin/src/assets/vue.svg b/resources/admin/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/resources/admin/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/admin/src/boot.js b/resources/admin/src/boot.js new file mode 100644 index 0000000..ee7ffbf --- /dev/null +++ b/resources/admin/src/boot.js @@ -0,0 +1,15 @@ +import * as AIcons from '@ant-design/icons-vue' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +export default { + install(app) { + + for (let icon in AIcons) { + app.component(`${icon}`, AIcons[icon]) + } + + for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(`El${key}`, component) + } + } +} diff --git a/resources/admin/src/components/HelloWorld.vue b/resources/admin/src/components/HelloWorld.vue new file mode 100644 index 0000000..546ebbc --- /dev/null +++ b/resources/admin/src/components/HelloWorld.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/resources/admin/src/components/scCron/index.vue b/resources/admin/src/components/scCron/index.vue new file mode 100644 index 0000000..b57a761 --- /dev/null +++ b/resources/admin/src/components/scCron/index.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/resources/admin/src/components/scEditor/UploadAdapter.js b/resources/admin/src/components/scEditor/UploadAdapter.js new file mode 100644 index 0000000..f689603 --- /dev/null +++ b/resources/admin/src/components/scEditor/UploadAdapter.js @@ -0,0 +1,144 @@ +export default class UploadAdapter { + constructor(loader, options) { + this.loader = loader; + this.options = options; + this.timeout = 60000; // 60秒超时 + } + + upload() { + return this.loader.file.then( + (file) => + new Promise((resolve, reject) => { + this._initRequest(); + this._initListeners(resolve, reject, file); + this._sendRequest(file); + this._initTimeout(reject); + }), + ); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + } + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + } + + _initRequest() { + const xhr = (this.xhr = new XMLHttpRequest()); + + xhr.open("POST", this.options.upload.uploadUrl, true); + xhr.responseType = "json"; + } + + _initListeners(resolve, reject, file) { + const xhr = this.xhr; + const loader = this.loader; + const genericErrorText = `Couldn't upload file: ${file.name}.`; + + xhr.addEventListener("error", () => { + console.error("[UploadAdapter] Upload error for file:", file.name); + reject(genericErrorText); + }); + + xhr.addEventListener("abort", () => { + console.warn("[UploadAdapter] Upload aborted for file:", file.name); + reject(); + }); + + xhr.addEventListener("timeout", () => { + console.error("[UploadAdapter] Upload timeout for file:", file.name); + reject(`Upload timeout: ${file.name}. Please try again.`); + }); + + xhr.addEventListener("load", () => { + const response = xhr.response; + + // 检查响应状态码 + if (xhr.status >= 200 && xhr.status < 300) { + if (!response) { + console.error("[UploadAdapter] Empty response for file:", file.name); + reject(genericErrorText); + return; + } + + // 检查业务状态码(假设 code=1 表示成功) + if (response.code == 1 || response.code == undefined) { + const url = response.data?.url || response.data?.src; + if (!url) { + console.error("[UploadAdapter] No URL in response for file:", file.name, response); + reject("Upload succeeded but no URL returned"); + return; + } + resolve({ default: url }); + } else { + const errorMessage = response.message || genericErrorText; + console.error("[UploadAdapter] Upload failed for file:", file.name, "Error:", errorMessage); + reject(errorMessage); + } + } else { + console.error("[UploadAdapter] HTTP error for file:", file.name, "Status:", xhr.status); + reject(`Server error (${xhr.status}): ${file.name}`); + } + }); + + // 上传进度监听 + if (xhr.upload) { + xhr.upload.addEventListener("progress", (evt) => { + if (evt.lengthComputable) { + loader.uploadTotal = evt.total; + loader.uploaded = evt.loaded; + } + }); + } + } + + _initTimeout(reject) { + // 清除之前的超时定时器(如果有) + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + // 设置新的超时定时器 + this.timeoutId = setTimeout(() => { + if (this.xhr) { + this.xhr.abort(); + reject(new Error("Upload timeout")); + } + }, this.timeout); + } + + _sendRequest(file) { + // 设置请求超时 + this.xhr.timeout = this.timeout; + + // Set headers if specified. + const headers = this.options.upload.headers || {}; + const extendData = this.options.upload.extendData || {}; + // Use the withCredentials flag if specified. + const withCredentials = this.options.upload.withCredentials || false; + const uploadName = this.options.upload.uploadName || "file"; + + for (const headerName of Object.keys(headers)) { + this.xhr.setRequestHeader(headerName, headers[headerName]); + } + + this.xhr.withCredentials = withCredentials; + + const data = new FormData(); + for (const key of Object.keys(extendData)) { + data.append(key, extendData[key]); + } + data.append(uploadName, file); + + this.xhr.send(data); + } +} + +export function UploadAdapterPlugin(editor) { + editor.plugins.get("FileRepository").createUploadAdapter = (loader) => { + return new UploadAdapter(loader, editor.config._config); + }; +} diff --git a/resources/admin/src/components/scEditor/index.vue b/resources/admin/src/components/scEditor/index.vue new file mode 100644 index 0000000..8be1b79 --- /dev/null +++ b/resources/admin/src/components/scEditor/index.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/resources/admin/src/components/scExport/README.md b/resources/admin/src/components/scExport/README.md new file mode 100644 index 0000000..8ea7951 --- /dev/null +++ b/resources/admin/src/components/scExport/README.md @@ -0,0 +1,394 @@ +# scExport 异步导出组件 + +异步导出组件,支持表单参数配置、字段选择、格式选择、自定义文件名等功能。 + +## 基本使用 + +```vue + + + +``` + +## Props + +| 参数 | 说明 | 类型 | 默认值 | +|------|------|------|--------| +| open | 是否显示弹窗 | Boolean | false | +| title | 弹窗标题 | String | '导出数据' | +| api | 导出API接口 | Function | 必填 | +| showOptions | 是否显示导出选项 | Boolean | true | +| showFieldSelect | 是否显示字段选择 | Boolean | false | +| fieldOptions | 字段选项 | Array | [] | +| showFormatSelect | 是否显示格式选择 | Boolean | false | +| defaultFormat | 默认导出格式 | String | 'xlsx' | +| defaultFilename | 默认文件名 | String | '' | +| tip | 提示信息 | String | '' | + +## Events + +| 事件名 | 说明 | 回调参数 | +|--------|------|----------| +| update:open | 弹窗显示状态变化 | (visible: Boolean) | +| success | 导出成功 | (exportParams) | +| error | 导出失败 | (message, error) | +| change | 导出参数变化 | (params) | + +## Slots + +### formParams + +自定义表单参数插槽,可用于添加额外的表单字段。 + +```vue + +``` + +## 完整示例 + +### 示例1:简单导出 + +```vue + + + +``` + +### 示例2:带表单参数的导出 + +```vue + + + +``` + +### 示例3:带字段和格式选择的导出 + +```vue + + + +``` + +## 注意事项 + +1. 组件会自动处理文件下载、表单参数合并等逻辑 +2. 表单参数会作为普通对象发送到后端 +3. 后端接口应返回 Blob 类型的响应 +4. 文件名会自动添加对应的扩展名(.xlsx、.xls、.csv) +5. 字段选择功能需要在后端支持按字段过滤数据 +6. 格式选择功能需要在后端支持不同格式的导出 + +## 与表格组件结合使用 + +```vue + + + diff --git a/resources/admin/src/components/scExport/index.vue b/resources/admin/src/components/scExport/index.vue new file mode 100644 index 0000000..c3573e9 --- /dev/null +++ b/resources/admin/src/components/scExport/index.vue @@ -0,0 +1,278 @@ + + + + + + + diff --git a/resources/admin/src/components/scForm/index.vue b/resources/admin/src/components/scForm/index.vue new file mode 100644 index 0000000..2ea8863 --- /dev/null +++ b/resources/admin/src/components/scForm/index.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/resources/admin/src/components/scIconPicker/index.vue b/resources/admin/src/components/scIconPicker/index.vue new file mode 100644 index 0000000..adbbb57 --- /dev/null +++ b/resources/admin/src/components/scIconPicker/index.vue @@ -0,0 +1,508 @@ + + + + + diff --git a/resources/admin/src/components/scImport/README.md b/resources/admin/src/components/scImport/README.md new file mode 100644 index 0000000..0cb7bac --- /dev/null +++ b/resources/admin/src/components/scImport/README.md @@ -0,0 +1,192 @@ +# scImport 异步导入组件 + +异步导入组件,支持文件上传、表单参数配置、模板下载等功能。 + +## 基本使用 + +```vue + + + +``` + +## Props + +| 参数 | 说明 | 类型 | 默认值 | +|------|------|------|--------| +| open | 是否显示弹窗 | Boolean | false | +| title | 弹窗标题 | String | '导入数据' | +| api | 导入API接口 | Function | 必填 | +| templateApi | 下载模板API接口 | Function | null | +| accept | 接受的文件类型 | String | '.xlsx,.xls,.csv' | +| maxSize | 文件大小限制(MB) | Number | 10 | +| showTemplate | 是否显示下载模板 | Boolean | true | +| tip | 提示信息 | String | '' | +| filename | 文件名(用于下载) | String | '导入数据' | + +## Events + +| 事件名 | 说明 | 回调参数 | +|--------|------|----------| +| update:open | 弹窗显示状态变化 | (visible: Boolean) | +| success | 导入成功 | (data, response) | +| error | 导出失败 | (message, error) | +| change | 文件列表变化 | (fileList) | + +## Slots + +### formParams + +自定义表单参数插槽,可用于添加额外的表单字段。 + +```vue + +``` + +## 完整示例 + +```vue + + + +``` + +## 注意事项 + +1. 组件会自动处理文件上传、表单参数合并等逻辑 +2. 表单参数会通过 FormData 发送到后端 +3. 数组和对象类型的参数会被转换为 JSON 字符串 +4. 下载模板功能需要后端提供对应的 API 接口 diff --git a/resources/admin/src/components/scImport/index.vue b/resources/admin/src/components/scImport/index.vue new file mode 100644 index 0000000..0eddb07 --- /dev/null +++ b/resources/admin/src/components/scImport/index.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/resources/admin/src/components/scTable/index.vue b/resources/admin/src/components/scTable/index.vue new file mode 100644 index 0000000..ef8681a --- /dev/null +++ b/resources/admin/src/components/scTable/index.vue @@ -0,0 +1,546 @@ + + + + + diff --git a/resources/admin/src/components/scUpload/file.vue b/resources/admin/src/components/scUpload/file.vue new file mode 100644 index 0000000..185face --- /dev/null +++ b/resources/admin/src/components/scUpload/file.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/resources/admin/src/components/scUpload/index.vue b/resources/admin/src/components/scUpload/index.vue new file mode 100644 index 0000000..b2afa4d --- /dev/null +++ b/resources/admin/src/components/scUpload/index.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/resources/admin/src/config/index.js b/resources/admin/src/config/index.js new file mode 100644 index 0000000..b15316c --- /dev/null +++ b/resources/admin/src/config/index.js @@ -0,0 +1,59 @@ +export default { + APP_NAME: 'vueadmin', + DASHBOARD_URL: '/dashboard', + + // 白名单路由(不需要登录即可访问) + whiteList: ['/login', '/register', '/reset-password'], + //版本号 + APP_VER: '1.6.6', + + //内核版本号 + CORE_VER: '1.6.6', + + //接口地址 + API_URL: 'http://127.0.0.1:8000/admin/', + + //请求超时 + TIMEOUT: 50000, + + //TokenName + TOKEN_NAME: 'authorization', + + //Token前缀,注意最后有个空格,如不需要需设置空字符串 + TOKEN_PREFIX: 'Bearer ', + + //追加其他头 + HEADERS: {}, + + //请求是否开启缓存 + REQUEST_CACHE: false, + //语言 + LANG: 'zh-cn', + + DASHBOARD_LAYOUT: 'widgets', //控制台首页默认布局 + DEFAULT_GRID: { + //默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6] + layout: [24, 12, 12], + //小组件分布,com取值:pages/home/components 文件名 + compsList: [["welcome"], ["info"], ["ver"]], + }, + + //是否加密localStorage, 为空不加密 + //支持多种加密方式: 'AES', 'BASE64', 'DES' + LS_ENCRYPTION: '', + + //localStorage加密秘钥,位数建议填写8的倍数 + LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4', + + //localStorage加密模式,AES支持: 'ECB', 'CBC', 'CTR', 'OFB', 'CFB' + LS_ENCRYPTION_mode: 'ECB', + + //localStorage加密填充方式,AES支持: 'Pkcs7', 'ZeroPadding', 'Iso10126', 'Iso97971' + LS_ENCRYPTION_padding: 'Pkcs7', + + //localStorage默认过期时间(单位:小时),0表示永不过期 + LS_DEFAULT_EXPIRE: 720, // 30天 + + //DES加密秘钥,必须是8字节 + LS_DES_key: '12345678', +} diff --git a/resources/admin/src/config/routes.js b/resources/admin/src/config/routes.js new file mode 100644 index 0000000..2139573 --- /dev/null +++ b/resources/admin/src/config/routes.js @@ -0,0 +1,7 @@ +/** + * 静态路由配置 + * 这些路由会根据用户角色进行过滤后添加到路由中 + */ +const userRoutes = [] + +export default userRoutes diff --git a/resources/admin/src/config/upload.js b/resources/admin/src/config/upload.js new file mode 100644 index 0000000..5429f34 --- /dev/null +++ b/resources/admin/src/config/upload.js @@ -0,0 +1,20 @@ +import systemApi from "@/api/system"; + +//上传配置 + +export default { + apiObj: systemApi.upload.post, //上传请求API对象 + filename: "file", //form请求时文件的key + successCode: 1, //请求完成代码 + maxSize: 10, //最大文件大小 默认10MB + parseData: function (res) { + return { + code: res.code, //分析状态字段结构 + fileName: res.data.name,//分析文件名称 + src: res.data.url, //分析图片远程地址结构 + msg: res.message //分析描述字段结构 + } + }, + apiObjFile: systemApi.upload.post, //附件上传请求API对象 + maxSizeFile: 10 //最大文件大小 默认10MB +} diff --git a/resources/admin/src/hooks/useI18n.js b/resources/admin/src/hooks/useI18n.js new file mode 100644 index 0000000..ef0f6fa --- /dev/null +++ b/resources/admin/src/hooks/useI18n.js @@ -0,0 +1,16 @@ +import { useI18n as useVueI18n } from 'vue-i18n' +import { useI18nStore } from '@/stores/modules/i18n' + +export function useI18n() { + const { t, locale, availableLocales } = useVueI18n() + const i18nStore = useI18nStore() + + return { + t, + locale, + availableLocales, + setLocale: i18nStore.setLocale, + currentLocale: i18nStore.currentLocale, + localeLabel: i18nStore.localeLabel + } +} diff --git a/resources/admin/src/hooks/useTable.js b/resources/admin/src/hooks/useTable.js new file mode 100644 index 0000000..b9850a4 --- /dev/null +++ b/resources/admin/src/hooks/useTable.js @@ -0,0 +1,248 @@ +import { ref, reactive, computed, onMounted } from 'vue' +import { message } from 'ant-design-vue' + +/** + * 表格通用hooks + * @param {Object} options 配置选项 + * @param {Function} options.api 获取列表数据的API函数,必须返回包含data和total的响应 + * @param {Object} options.searchForm 搜索表单的初始值 + * @param {Array} options.columns 表格列配置 + * @param {String} options.rowKey 行的唯一标识,默认为'id' + * @param {Boolean} options.needPagination 是否需要分页,默认为true + * @param {Object} options.paginationConfig 分页配置,可选 + * @param {Boolean} options.needSelection 是否需要行选择,默认为false + * @param {Boolean} options.immediateLoad 是否在组件挂载时自动加载数据,默认为true + * @returns {Object} 返回表格相关的状态和方法 + */ +export function useTable(options = {}) { + const { + api, + searchForm: initialSearchForm = {}, + columns = [], + rowKey = 'id', + needPagination = true, + paginationConfig = {}, + needSelection = false, + immediateLoad = true + } = options + + // 表格引用 + const tableRef = ref(null) + + // 搜索表单 + const searchForm = reactive({ ...initialSearchForm }) + + // 表格数据 + const tableData = ref([]) + + // 加载状态 + const loading = ref(false) + + // 选中的行数据 + const selectedRows = ref([]) + + // 选中的行keys + const selectedRowKeys = computed(() => selectedRows.value.map(item => item[rowKey])) + + // 分页配置 + const defaultPaginationConfig = { + current: 1, + pageSize: 20, + total: 0, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + pageSizeOptions: ['20', '50', '100', '200'] + } + + const pagination = reactive({ + ...defaultPaginationConfig, + ...paginationConfig + }) + + // 行选择配置 + const rowSelection = computed(() => { + if (!needSelection) return null + return { + selectedRowKeys: selectedRowKeys.value, + onChange: (keys, rows) => { + selectedRows.value = rows + } + } + }) + + // 行选择事件处理(用于scTable的@select事件) + const handleSelectChange = (record, selected, selectedRows) => { + if (!needSelection) return + if (selected) { + selectedRows.value.push(record) + } else { + const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey]) + if (index > -1) { + selectedRows.value.splice(index, 1) + } + } + } + + // 全选/取消全选处理(用于scTable的@selectAll事件) + const handleSelectAll = (selected, selectedRows, changeRows) => { + if (!needSelection) return + if (selected) { + changeRows.forEach(record => { + if (!selectedRows.value.find(item => item[rowKey] === record[rowKey])) { + selectedRows.value.push(record) + } + }) + } else { + changeRows.forEach(record => { + const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey]) + if (index > -1) { + selectedRows.value.splice(index, 1) + } + }) + } + } + + // 加载数据 + const loadData = async (params = {}) => { + if (!api) { + console.warn('useTable: 未提供api函数,无法加载数据') + return + } + + loading.value = true + try { + const requestParams = { + ...searchForm, + ...params + } + + // 如果需要分页,添加分页参数 + if (needPagination) { + requestParams.page = pagination.current + requestParams.limit = pagination.pageSize + } + + // 调用API函数,确保this上下文正确 + const res = await api(requestParams) + + if (res.code === 1) { + // 如果是分页数据 + if (needPagination) { + tableData.value = res.data?.data || [] + pagination.total = res.data?.total || 0 + } else { + // 非分页数据(如树形数据) + // 确保数据是数组,如果不是数组则包装成数组 + const data = res.data + if (Array.isArray(data)) { + tableData.value = data + } else if (data && typeof data === 'object') { + // 如果返回的是对象,可能包含 list 或 items 等字段 + tableData.value = data.list || data.items || data.data || [] + } else { + tableData.value = [] + } + } + } else { + message.error(res.message || '加载数据失败') + } + } catch (error) { + console.error('加载数据失败:', error) + message.error('加载数据失败') + } finally { + loading.value = false + } + } + + // 分页变化处理 + const handlePaginationChange = ({ page, pageSize }) => { + if (!needPagination) return + pagination.current = page + pagination.pageSize = pageSize + loadData() + } + + // 搜索 + const handleSearch = () => { + if (needPagination) { + pagination.current = 1 + } + loadData() + } + + // 重置 + const handleReset = () => { + // 重置搜索表单为初始值 + Object.keys(searchForm).forEach(key => { + searchForm[key] = initialSearchForm[key] + }) + // 清空选择 + selectedRows.value = [] + // 重置分页 + if (needPagination) { + pagination.current = 1 + } + // 重新加载数据 + loadData() + } + + // 刷新表格 + const refreshTable = () => { + loadData() + } + + // 清空选择 + const clearSelection = () => { + selectedRows.value = [] + } + + // 设置选中行 + const setSelectedRows = (rows) => { + selectedRows.value = rows + } + + // 更新搜索表单 + const setSearchForm = (data) => { + Object.assign(searchForm, data) + } + + // 直接设置表格数据(用于特殊场景) + const setTableData = (data) => { + tableData.value = data + } + + // 组件挂载时自动加载数据 + if (immediateLoad) { + onMounted(() => { + loadData() + }) + } + + return { + // ref + tableRef, + // 响应式数据 + searchForm, + tableData, + loading, + pagination, + selectedRows, + selectedRowKeys, + // 配置 + columns, + rowKey, + rowSelection, + // 方法 + loadData, + handleSearch, + handleReset, + handlePaginationChange, + handleSelectChange, + handleSelectAll, + refreshTable, + clearSelection, + setSelectedRows, + setSearchForm, + setTableData + } +} diff --git a/resources/admin/src/i18n/index.js b/resources/admin/src/i18n/index.js new file mode 100644 index 0000000..686b68a --- /dev/null +++ b/resources/admin/src/i18n/index.js @@ -0,0 +1,15 @@ +import { createI18n } from 'vue-i18n' +import zh from './locales/zh-CN' +import en from './locales/en-US' + +const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + fallbackLocale: 'en-US', + messages: { + 'zh-CN': zh, + 'en-US': en + } +}) + +export default i18n diff --git a/resources/admin/src/i18n/locales/en-US.js b/resources/admin/src/i18n/locales/en-US.js new file mode 100644 index 0000000..8955705 --- /dev/null +++ b/resources/admin/src/i18n/locales/en-US.js @@ -0,0 +1,249 @@ +export default { + common: { + welcome: 'Welcome', + login: 'Login', + logout: 'Logout', + register: 'Register', + searchMenu: 'Search Menu', + searchPlaceholder: 'Please enter menu name to search', + noResults: 'No matching menus found', + searchTips: 'Keyboard Shortcuts Tips', + navigateResults: 'Use up/down arrows to navigate', + selectResult: 'Press Enter to select', + closeSearch: 'Press ESC to close', + taskCenter: 'Task Center', + totalTasks: 'Total Tasks', + pendingTasks: 'Pending', + completedTasks: 'Completed', + searchTasks: 'Search tasks...', + all: 'All', + pending: 'Pending', + completed: 'Completed', + taskTitle: 'Task Title', + enterTaskTitle: 'Please enter task title', + taskPriority: 'Task Priority', + priorityHigh: 'High', + priorityMedium: 'Medium', + priorityLow: 'Low', + confirmDelete: 'Confirm Delete', + addTask: 'Add Task', + pleaseEnterTaskTitle: 'Please enter task title', + added: 'Added', + deleted: 'Deleted', + justNow: 'Just now', + clearCache: 'Clear Cache', + confirmClearCache: 'Confirm Clear Cache', + clearCacheConfirm: 'Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.', + cacheCleared: 'Cache cleared', + clearCacheFailed: 'Failed to clear cache', + messages: 'Messages', + tasks: 'Tasks', + clearAll: 'Clear All', + noMessages: 'No Messages', + noTasks: 'No Tasks', + fullscreen: 'Fullscreen', + personalCenter: 'Personal Center', + systemSettings: 'System Settings', + searchEmpty: 'Please enter search content', + searching: 'Searching: ', + cleared: 'Cleared', + languageChanged: 'Language Changed', + settingsDeveloping: 'System settings feature is under development', + logoutSuccess: 'Logout Successful', + logoutFailed: 'Logout Failed', + confirmLogout: 'Confirm Logout', + logoutConfirm: 'Are you sure you want to logout?', + username: 'Username', + password: 'Password', + confirmPassword: 'Confirm Password', + email: 'Email', + phone: 'Phone', + rememberMe: 'Remember Me', + forgotPassword: 'Forgot Password?', + submit: 'Submit', + cancel: 'Cancel', + save: 'Save', + edit: 'Edit', + delete: 'Delete', + add: 'Add', + search: 'Search', + reset: 'Reset', + confirm: 'Confirm', + back: 'Back', + next: 'Next', + previous: 'Previous', + refresh: 'Refresh', + export: 'Export', + import: 'Import', + download: 'Download', + upload: 'Upload', + view: 'View', + detail: 'Detail', + settings: 'Settings', + profile: 'Profile', + language: 'Language', + theme: 'Theme', + dark: 'Dark', + light: 'Light', + loading: 'Loading...', + noData: 'No Data', + success: 'Operation Successful', + error: 'Operation Failed', + warning: 'Warning', + info: 'Info', + confirmDelete: 'Are you sure you want to delete?', + confirmLogout: 'Are you sure you want to logout?', + addConfig: 'Add Config', + editConfig: 'Edit Config', + configCategory: 'Config Category', + configName: 'Config Name', + configTitle: 'Config Title', + configType: 'Config Type', + configValue: 'Config Value', + configTip: 'Config Tip', + typeText: 'Text', + typeTextarea: 'Textarea', + typeNumber: 'Number', + typeSwitch: 'Switch', + typeSelect: 'Select', + typeMultiselect: 'Multiselect', + typeDatetime: 'Datetime', + typeColor: 'Color', + pleaseSelect: 'Please Select', + pleaseEnter: 'Please Enter', + noConfig: 'No Config', + fetchConfigFailed: 'Failed to fetch config', + addSuccess: 'Added Successfully', + addFailed: 'Failed to Add', + editSuccess: 'Edited Successfully', + editFailed: 'Failed to Edit', + saveSuccess: 'Saved Successfully', + saveFailed: 'Failed to Save', + resetSuccess: 'Reset Successfully', + required: 'This field is required', + operation: 'Operation', + time: 'Time', + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + yes: 'Yes', + no: 'No', + areaManage: 'Area Management', + areaName: 'Area Name', + areaCode: 'Area Code', + areaLevel: 'Area Level', + parentArea: 'Parent Area', + province: 'Province', + city: 'City', + district: 'District', + street: 'Street', + unknown: 'Unknown', + addArea: 'Add Area', + editArea: 'Edit Area', + remark: 'Remark', + sort: 'Sort', + createTime: 'Create Time', + action: 'Action', + batchDelete: 'Batch Delete', + confirmBatchDelete: 'Confirm Batch Delete', + batchDeleteConfirm: 'Are you sure you want to delete the selected', + items: 'items?', + deleteConfirm: 'Are you sure you want to delete', + selectDataFirst: 'Please select data to operate first', + pleaseEnterNumber: 'Please enter a valid number', + exitFullScreen: 'Exit Fullscreen', + columns: 'Columns', + columnSettings: 'Column Settings', + selectAll: 'Select All', + unselectAll: 'Unselect All', + retry: 'Retry', + fetchDataFailed: 'Failed to fetch data' + }, + menu: { + dashboard: 'Dashboard', + userManagement: 'User Management', + roleManagement: 'Role Management', + permissionManagement: 'Permission Management', + systemSettings: 'System Settings', + logManagement: 'Log Management' + }, + login: { + title: 'User Login', + subtitle: 'Welcome back, please login to your account', + loginButton: 'Login', + loginSuccess: 'Login Successful', + loginFailed: 'Login Failed', + usernamePlaceholder: 'Please enter username', + passwordPlaceholder: 'Please enter password', + noAccount: "Don't have an account?", + registerNow: 'Register Now', + forgotPassword: 'Forgot Password?', + rememberMe: 'Remember Me' + }, + register: { + title: 'User Registration', + subtitle: 'Create your account and get started', + registerButton: 'Register', + registerSuccess: 'Registration Successful', + registerFailed: 'Registration Failed', + usernamePlaceholder: 'Please enter username', + emailPlaceholder: 'Please enter email address', + passwordPlaceholder: 'Please enter password', + confirmPasswordPlaceholder: 'Please enter password again', + usernameRule: 'Username length between 3 to 20 characters', + emailRule: 'Please enter a valid email address', + passwordRule: 'Password length between 6 to 20 characters', + agreeRule: 'Please agree to the user agreement', + agreeTerms: 'I have read and agree to the', + terms: 'User Agreement', + hasAccount: 'Already have an account?', + loginNow: 'Login Now' + }, + resetPassword: { + title: 'Reset Password', + subtitle: 'Reset your password via email verification code', + resetButton: 'Reset Password', + resetSuccess: 'Password reset successful', + resetFailed: 'Reset failed', + emailPlaceholder: 'Please enter email address', + codePlaceholder: 'Please enter verification code', + newPasswordPlaceholder: 'Please enter new password', + confirmPasswordPlaceholder: 'Please enter new password again', + emailRule: 'Please enter a valid email address', + codeRule: 'Verification code must be 6 characters', + passwordRule: 'Password length between 6 to 20 characters', + sendCode: 'Send Code', + codeSent: 'Verification code has been sent to your email', + resendCode: 'Resend in {seconds} seconds', + sendCodeFirst: 'Please enter email address first', + backToLogin: 'Back to Login' + }, + layout: { + toggleSidebar: 'Toggle Sidebar', + collapse: 'Collapse', + expand: 'Expand', + logout: 'Logout' + }, + table: { + total: 'Total {total} items', + selected: '{selected} items selected', + actions: 'Actions', + noData: 'No Data', + sort: 'Sort', + filter: 'Filter' + }, + pagination: { + goTo: 'Go to', + page: 'Page', + total: 'Total {total} items', + itemsPerPage: '{size} items per page' + }, + form: { + required: 'This field is required', + invalidEmail: 'Please enter a valid email address', + invalidPhone: 'Please enter a valid phone number', + passwordMismatch: 'Passwords do not match', + minLength: 'Minimum {min} characters required', + maxLength: 'Maximum {max} characters allowed' + } +} diff --git a/resources/admin/src/i18n/locales/zh-CN.js b/resources/admin/src/i18n/locales/zh-CN.js new file mode 100644 index 0000000..7f6e6ff --- /dev/null +++ b/resources/admin/src/i18n/locales/zh-CN.js @@ -0,0 +1,248 @@ +export default { + common: { + welcome: '欢迎使用', + login: '登录', + logout: '退出登录', + register: '注册', + searchMenu: '搜索菜单', + searchPlaceholder: '请输入菜单名称进行搜索', + noResults: '未找到匹配的菜单', + searchTips: '快捷键操作提示', + navigateResults: '使用上下键导航', + selectResult: '按回车键选择', + closeSearch: '按 ESC 关闭', + taskCenter: '任务中心', + totalTasks: '总任务', + pendingTasks: '待完成', + completedTasks: '已完成', + searchTasks: '搜索任务...', + all: '全部', + pending: '待完成', + completed: '已完成', + taskTitle: '任务标题', + enterTaskTitle: '请输入任务标题', + taskPriority: '任务优先级', + priorityHigh: '高', + priorityMedium: '中', + priorityLow: '低', + confirmDelete: '确认删除', + addTask: '添加任务', + pleaseEnterTaskTitle: '请输入任务标题', + added: '已添加', + deleted: '已删除', + justNow: '刚刚', + clearCache: '清除缓存', + confirmClearCache: '确认清除缓存', + clearCacheConfirm: '确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。', + cacheCleared: '缓存已清除', + clearCacheFailed: '清除缓存失败', + messages: '消息', + tasks: '任务', + clearAll: '清空全部', + noMessages: '暂无消息', + noTasks: '暂无任务', + fullscreen: '全屏', + personalCenter: '个人中心', + systemSettings: '系统设置', + searchEmpty: '请输入搜索内容', + searching: '正在搜索:', + cleared: '已清空', + languageChanged: '语言已切换', + settingsDeveloping: '系统设置功能开发中', + logoutSuccess: '退出成功', + logoutFailed: '退出失败', + confirmLogout: '确认退出', + logoutConfirm: '确定要退出登录吗?', + username: '用户名', + password: '密码', + confirmPassword: '确认密码', + email: '邮箱', + phone: '手机号', + rememberMe: '记住我', + forgotPassword: '忘记密码?', + submit: '提交', + cancel: '取消', + save: '保存', + edit: '编辑', + delete: '删除', + add: '添加', + search: '搜索', + reset: '重置', + confirm: '确认', + back: '返回', + next: '下一步', + previous: '上一步', + refresh: '刷新', + export: '导出', + import: '导入', + download: '下载', + upload: '上传', + view: '查看', + detail: '详情', + settings: '设置', + profile: '个人资料', + language: '语言', + theme: '主题', + dark: '暗色', + light: '亮色', + loading: '加载中...', + noData: '暂无数据', + success: '操作成功', + error: '操作失败', + warning: '警告', + info: '提示', + confirmDelete: '确定要删除吗?', + confirmLogout: '确定要退出登录吗?', + addConfig: '添加配置', + editConfig: '编辑配置', + configCategory: '配置分类', + configName: '配置名称', + configTitle: '配置标题', + configType: '配置类型', + configValue: '配置值', + configTip: '配置提示', + typeText: '文本', + typeTextarea: '文本域', + typeNumber: '数字', + typeSwitch: '开关', + typeSelect: '下拉选择', + typeMultiselect: '多选', + typeDatetime: '日期时间', + typeColor: '颜色', + pleaseSelect: '请选择', + pleaseEnter: '请输入', + noConfig: '暂无配置', + fetchConfigFailed: '获取配置失败', + addSuccess: '添加成功', + addFailed: '添加失败', + editSuccess: '编辑成功', + editFailed: '编辑失败', + saveSuccess: '保存成功', + saveFailed: '保存失败', + resetSuccess: '重置成功', + required: '此项为必填项', + operation: '操作', + time: '时间', + status: '状态', + enabled: '启用', + disabled: '禁用', + yes: '是', + no: '否', + areaManage: '地区管理', + areaName: '地区名称', + areaCode: '地区编码', + areaLevel: '地区级别', + parentArea: '上级地区', + province: '省份', + city: '城市', + district: '区县', + street: '街道', + unknown: '未知', + addArea: '添加地区', + editArea: '编辑地区', + remark: '备注', + sort: '排序', + createTime: '创建时间', + action: '操作', + batchDelete: '批量删除', + confirmBatchDelete: '确认批量删除', + batchDeleteConfirm: '确定要删除选中的', + items: '条数据吗?', + deleteConfirm: '确定要删除', + selectDataFirst: '请先选择要操作的数据', + pleaseEnterNumber: '请输入有效的数字', + exitFullScreen: '退出全屏', + columns: '列设置', + columnSettings: '列显示设置', + selectAll: '全选', + unselectAll: '取消全选', + retry: '重试' + }, + menu: { + dashboard: '仪表板', + userManagement: '用户管理', + roleManagement: '角色管理', + permissionManagement: '权限管理', + systemSettings: '系统设置', + logManagement: '日志管理' + }, + login: { + title: '用户登录', + subtitle: '欢迎回来,请登录您的账户', + loginButton: '登录', + loginSuccess: '登录成功', + loginFailed: '登录失败', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', + noAccount: '还没有账户?', + registerNow: '立即注册', + forgotPassword: '忘记密码?', + rememberMe: '记住我' + }, + register: { + title: '用户注册', + subtitle: '创建您的账户,开始使用', + registerButton: '注册', + registerSuccess: '注册成功', + registerFailed: '注册失败', + usernamePlaceholder: '请输入用户名', + emailPlaceholder: '请输入邮箱地址', + passwordPlaceholder: '请输入密码', + confirmPasswordPlaceholder: '请再次输入密码', + usernameRule: '用户名长度在 3 到 20 个字符', + emailRule: '请输入正确的邮箱地址', + passwordRule: '密码长度在 6 到 20 个字符', + agreeRule: '请同意用户协议', + agreeTerms: '我已阅读并同意', + terms: '用户协议', + hasAccount: '已有账户?', + loginNow: '立即登录' + }, + resetPassword: { + title: '重置密码', + subtitle: '通过邮箱验证码重置您的密码', + resetButton: '重置密码', + resetSuccess: '密码重置成功', + resetFailed: '重置失败', + emailPlaceholder: '请输入邮箱地址', + codePlaceholder: '请输入验证码', + newPasswordPlaceholder: '请输入新密码', + confirmPasswordPlaceholder: '请再次输入新密码', + emailRule: '请输入正确的邮箱地址', + codeRule: '验证码长度为6位', + passwordRule: '密码长度在 6 到 20 个字符', + sendCode: '发送验证码', + codeSent: '验证码已发送到您的邮箱', + resendCode: '{seconds}秒后重新发送', + sendCodeFirst: '请先输入邮箱地址', + backToLogin: '返回登录' + }, + layout: { + toggleSidebar: '切换侧边栏', + collapse: '折叠', + expand: '展开', + logout: '退出登录' + }, + table: { + total: '共 {total} 条', + selected: '已选择 {selected} 项', + actions: '操作', + noData: '暂无数据', + sort: '排序', + filter: '筛选' + }, + pagination: { + goTo: '前往', + page: '页', + total: '共 {total} 条', + itemsPerPage: '每页 {size} 条' + }, + form: { + required: '此项为必填项', + invalidEmail: '请输入有效的邮箱地址', + invalidPhone: '请输入有效的手机号', + passwordMismatch: '两次输入的密码不一致', + minLength: '最少需要 {min} 个字符', + maxLength: '最多允许 {max} 个字符' + } +} diff --git a/resources/admin/src/layouts/components/breadcrumb.vue b/resources/admin/src/layouts/components/breadcrumb.vue new file mode 100644 index 0000000..f1a46ff --- /dev/null +++ b/resources/admin/src/layouts/components/breadcrumb.vue @@ -0,0 +1,55 @@ + + + diff --git a/resources/admin/src/layouts/components/navMenu.vue b/resources/admin/src/layouts/components/navMenu.vue new file mode 100644 index 0000000..798fb87 --- /dev/null +++ b/resources/admin/src/layouts/components/navMenu.vue @@ -0,0 +1,54 @@ + + + diff --git a/resources/admin/src/layouts/components/search.vue b/resources/admin/src/layouts/components/search.vue new file mode 100644 index 0000000..a64472f --- /dev/null +++ b/resources/admin/src/layouts/components/search.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/resources/admin/src/layouts/components/setting.vue b/resources/admin/src/layouts/components/setting.vue new file mode 100644 index 0000000..708f01e --- /dev/null +++ b/resources/admin/src/layouts/components/setting.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/resources/admin/src/layouts/components/sideMenu.vue b/resources/admin/src/layouts/components/sideMenu.vue new file mode 100644 index 0000000..bf5674e --- /dev/null +++ b/resources/admin/src/layouts/components/sideMenu.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/resources/admin/src/layouts/components/tags.vue b/resources/admin/src/layouts/components/tags.vue new file mode 100644 index 0000000..86f39f1 --- /dev/null +++ b/resources/admin/src/layouts/components/tags.vue @@ -0,0 +1,487 @@ + + + + + diff --git a/resources/admin/src/layouts/components/task.vue b/resources/admin/src/layouts/components/task.vue new file mode 100644 index 0000000..c0314bc --- /dev/null +++ b/resources/admin/src/layouts/components/task.vue @@ -0,0 +1,448 @@ + + + + + diff --git a/resources/admin/src/layouts/components/userbar.vue b/resources/admin/src/layouts/components/userbar.vue new file mode 100644 index 0000000..cb2f9f2 --- /dev/null +++ b/resources/admin/src/layouts/components/userbar.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/resources/admin/src/layouts/index.vue b/resources/admin/src/layouts/index.vue new file mode 100644 index 0000000..b3ec299 --- /dev/null +++ b/resources/admin/src/layouts/index.vue @@ -0,0 +1,665 @@ + + + + + diff --git a/resources/admin/src/layouts/other/404.vue b/resources/admin/src/layouts/other/404.vue new file mode 100644 index 0000000..8ea30db --- /dev/null +++ b/resources/admin/src/layouts/other/404.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/resources/admin/src/layouts/other/empty.vue b/resources/admin/src/layouts/other/empty.vue new file mode 100644 index 0000000..74fe604 --- /dev/null +++ b/resources/admin/src/layouts/other/empty.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/resources/admin/src/main.js b/resources/admin/src/main.js new file mode 100644 index 0000000..e70ebe6 --- /dev/null +++ b/resources/admin/src/main.js @@ -0,0 +1,20 @@ +import { createApp } from 'vue' + +import Antd from 'ant-design-vue' +import 'ant-design-vue/dist/reset.css' +import '@/assets/style/app.scss' +import App from './App.vue' +import router from './router' +import pinia from './stores' +import i18n from './i18n' +import boot from './boot' + +const app = createApp(App) + +app.use(Antd) +app.use(router) +app.use(pinia) +app.use(i18n) +app.use(boot) + +app.mount('#app') diff --git a/resources/admin/src/pages/home/iconPickerDemo.vue b/resources/admin/src/pages/home/iconPickerDemo.vue new file mode 100644 index 0000000..cf653ac --- /dev/null +++ b/resources/admin/src/pages/home/iconPickerDemo.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/resources/admin/src/pages/home/index.vue b/resources/admin/src/pages/home/index.vue new file mode 100644 index 0000000..e3dd744 --- /dev/null +++ b/resources/admin/src/pages/home/index.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/about.vue b/resources/admin/src/pages/home/widgets/components/about.vue new file mode 100644 index 0000000..b904c7f --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/about.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/echarts.vue b/resources/admin/src/pages/home/widgets/components/echarts.vue new file mode 100644 index 0000000..fe47293 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/echarts.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/index.js b/resources/admin/src/pages/home/widgets/components/index.js new file mode 100644 index 0000000..44be942 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/index.js @@ -0,0 +1,8 @@ +import { markRaw } from 'vue' +const resultComps = {} +const files = import.meta.glob('./*.vue', { eager: true }) +Object.keys(files).forEach((fileName) => { + let comp = files[fileName] + resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default +}) +export default markRaw(resultComps) diff --git a/resources/admin/src/pages/home/widgets/components/info.vue b/resources/admin/src/pages/home/widgets/components/info.vue new file mode 100644 index 0000000..f3e2602 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/info.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/progress.vue b/resources/admin/src/pages/home/widgets/components/progress.vue new file mode 100644 index 0000000..90d5c88 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/progress.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/sms.vue b/resources/admin/src/pages/home/widgets/components/sms.vue new file mode 100644 index 0000000..272a421 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/sms.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/time.vue b/resources/admin/src/pages/home/widgets/components/time.vue new file mode 100644 index 0000000..0c0e4c3 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/time.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/ver.vue b/resources/admin/src/pages/home/widgets/components/ver.vue new file mode 100644 index 0000000..34484f4 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/ver.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/welcome.vue b/resources/admin/src/pages/home/widgets/components/welcome.vue new file mode 100644 index 0000000..1bf13ed --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/welcome.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/index.vue b/resources/admin/src/pages/home/widgets/index.vue new file mode 100644 index 0000000..4a84f5c --- /dev/null +++ b/resources/admin/src/pages/home/widgets/index.vue @@ -0,0 +1,505 @@ + + + + + diff --git a/resources/admin/src/pages/home/work/components/myapp.vue b/resources/admin/src/pages/home/work/components/myapp.vue new file mode 100644 index 0000000..1a941cd --- /dev/null +++ b/resources/admin/src/pages/home/work/components/myapp.vue @@ -0,0 +1,469 @@ + + + + + diff --git a/resources/admin/src/pages/home/work/index.vue b/resources/admin/src/pages/home/work/index.vue new file mode 100644 index 0000000..c1cf14a --- /dev/null +++ b/resources/admin/src/pages/home/work/index.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/resources/admin/src/pages/login/index.vue b/resources/admin/src/pages/login/index.vue new file mode 100644 index 0000000..30de3cc --- /dev/null +++ b/resources/admin/src/pages/login/index.vue @@ -0,0 +1,163 @@ + + + diff --git a/resources/admin/src/pages/login/resetPassword.vue b/resources/admin/src/pages/login/resetPassword.vue new file mode 100644 index 0000000..ad33bf1 --- /dev/null +++ b/resources/admin/src/pages/login/resetPassword.vue @@ -0,0 +1,164 @@ + + + diff --git a/resources/admin/src/pages/login/userRegister.vue b/resources/admin/src/pages/login/userRegister.vue new file mode 100644 index 0000000..1c4b77d --- /dev/null +++ b/resources/admin/src/pages/login/userRegister.vue @@ -0,0 +1,161 @@ + + + diff --git a/resources/admin/src/pages/ucenter/components/BasicInfo.vue b/resources/admin/src/pages/ucenter/components/BasicInfo.vue new file mode 100644 index 0000000..157dbb4 --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/BasicInfo.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/components/Password.vue b/resources/admin/src/pages/ucenter/components/Password.vue new file mode 100644 index 0000000..427132c --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/Password.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/components/ProfileInfo.vue b/resources/admin/src/pages/ucenter/components/ProfileInfo.vue new file mode 100644 index 0000000..bd1b3dc --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/ProfileInfo.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/components/Security.vue b/resources/admin/src/pages/ucenter/components/Security.vue new file mode 100644 index 0000000..c953293 --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/Security.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/index.vue b/resources/admin/src/pages/ucenter/index.vue new file mode 100644 index 0000000..ae0dbc3 --- /dev/null +++ b/resources/admin/src/pages/ucenter/index.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/resources/admin/src/router/index.js b/resources/admin/src/router/index.js new file mode 100644 index 0000000..336f34a --- /dev/null +++ b/resources/admin/src/router/index.js @@ -0,0 +1,205 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' +import config from '../config' +import { useUserStore } from '../stores/modules/user' +import systemRoutes from './systemRoutes' + +// 配置 NProgress +NProgress.configure({ + showSpinner: false, + trickleSpeed: 200, + minimum: 0.3 +}) + +/** + * 404 路由 + */ +const notFoundRoute = { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('../layouts/other/404.vue'), + meta: { + title: '404', + hidden: true + } +} + +// 创建路由实例 +const router = createRouter({ + history: createWebHashHistory(), + routes: systemRoutes +}) + +/** + * 组件导入映射 + */ +const modules = import.meta.glob('../pages/**/*.vue') + +/** + * 动态加载组件 + * @param {string} componentPath - 组件路径 + * @returns {Promise} 组件 + */ +function loadComponent(componentPath) { + // 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载 + if (componentPath.startsWith('views/')) { + const path = componentPath.replace('views/', '../pages/') + return modules[`${path}.vue`] + } + + // 如果是简单的组件名称,从 pages 目录加载 + return modules[`../pages/${componentPath}/index.vue`] +} + +/** + * 将后端菜单转换为路由格式 + * @param {Array} menus - 后端返回的菜单数据 + * @returns {Array} 路由数组 + */ +function transformMenusToRoutes(menus) { + if (!menus || !Array.isArray(menus)) { + return [] + } + + return menus + .filter(menu => menu && menu.path) + .map(menu => { + const route = { + path: menu.path, + name: menu.name || menu.path.replace(/\//g, '-'), + meta: { + title: menu.meta?.title || menu.title, + icon: menu.meta?.icon || menu.icon, + hidden: menu.hidden || menu.meta?.hidden, + keepAlive: menu.meta?.keepAlive || false, + affix: menu.meta?.affix || 0, + role: menu.meta?.role || [] + } + } + + // 处理组件 + if (menu.component) { + route.component = loadComponent(menu.component) + } + + // 处理子路由 + if (menu.children && menu.children.length > 0) { + route.children = transformMenusToRoutes(menu.children) + } + + // 处理重定向 + if (menu.redirect) { + route.redirect = menu.redirect + } + + return route + }) +} + +/** + * 路由守卫 + */ +let isDynamicRouteLoaded = false + +router.beforeEach(async (to, from, next) => { + // 开始进度条 + NProgress.start() + + // 设置页面标题 + document.title = to.meta.title + ? `${to.meta.title} - ${config.APP_NAME}` + : config.APP_NAME + + const userStore = useUserStore() + const isLoggedIn = userStore.isLoggedIn() + const whiteList = config.whiteList || [] + + // 1. 如果在白名单中,直接放行 + if (whiteList.includes(to.path)) { + next() + return + } + + // 2. 如果未登录,跳转到登录页 + if (!isLoggedIn) { + // 保存目标路由,登录后跳转 + next({ + path: '/login', + query: { redirect: to.fullPath } + }) + return + } + + // 3. 已登录情况 + // 如果访问登录页,重定向到首页 + if (to.path === '/login') { + next({ path: config.DASHBOARD_URL }) + return + } + + // 4. 动态路由加载 + if (!isDynamicRouteLoaded) { + try { + // 获取后端返回的用户菜单 + const mergedMenus = userStore.getMenu() + + if (mergedMenus && mergedMenus.length > 0) { + // 将合并后的菜单转换为路由 + const dynamicRoutes = transformMenusToRoutes(mergedMenus) + + // 添加动态路由到 Layout 的子路由 + dynamicRoutes.forEach(route => { + router.addRoute('Layout', route) + }) + + // 添加 404 路由(必须在最后添加) + router.addRoute(notFoundRoute) + + isDynamicRouteLoaded = true + + // 重新导航,确保新添加的路由被正确匹配 + next({ ...to, replace: true }) + } else { + // 如果没有菜单数据,重置并跳转到登录页 + userStore.logout() + next({ path: '/login', query: { redirect: to.fullPath } }) + } + } catch (error) { + console.error('动态路由加载失败:', error) + + // 加载失败,清除用户信息并跳转到登录页 + userStore.logout() + next({ + path: '/login', + query: { redirect: to.fullPath } + }) + } + } else { + // 动态路由已加载,直接放行 + next() + } +}) + +router.afterEach(() => { + // 结束进度条 + NProgress.done() +}) + +/** + * 重置路由(用于登出时) + */ +export function resetRouter() { + // 移除所有动态添加的路由 + isDynamicRouteLoaded = false + + // 重置为初始路由 + const newRouter = createRouter({ + history: createWebHashHistory(), + routes: systemRoutes + }) + + router.matcher = newRouter.matcher +} + +export default router diff --git a/resources/admin/src/router/systemRoutes.js b/resources/admin/src/router/systemRoutes.js new file mode 100644 index 0000000..8153830 --- /dev/null +++ b/resources/admin/src/router/systemRoutes.js @@ -0,0 +1,43 @@ +import config from '@/config' + +/** + * 基础路由(不需要登录) + */ +const systemRoutes = [ + { + path: '/login', + name: 'Login', + component: () => import('../pages/login/index.vue'), + meta: { + title: 'login', + hidden: true, + }, + }, + { + path: '/register', + name: 'Register', + component: () => import('../pages/login/userRegister.vue'), + meta: { + title: 'register', + hidden: true, + }, + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: () => import('../pages/login/resetPassword.vue'), + meta: { + title: 'resetPassword', + hidden: true, + }, + }, + { + path: '/', + name: 'Layout', + component: () => import('@/layouts/index.vue'), + redirect: config.DASHBOARD_URL, + children: [], + }, +] + +export default systemRoutes diff --git a/resources/admin/src/stores/index.js b/resources/admin/src/stores/index.js new file mode 100644 index 0000000..bb44b70 --- /dev/null +++ b/resources/admin/src/stores/index.js @@ -0,0 +1,9 @@ +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +const pinia = createPinia() + +// 注册持久化插件 +pinia.use(piniaPluginPersistedstate) + +export default pinia diff --git a/resources/admin/src/stores/modules/i18n.js b/resources/admin/src/stores/modules/i18n.js new file mode 100644 index 0000000..9373b1c --- /dev/null +++ b/resources/admin/src/stores/modules/i18n.js @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import i18n from '@/i18n' +import { customStorage } from '../persist' + +export const useI18nStore = defineStore( + 'i18n', + { + state: () => ({ + currentLocale: 'zh-CN', + availableLocales: [ + { label: '简体中文', value: 'zh-CN' }, + { label: 'English', value: 'en-US' } + ] + }), + + getters: { + localeLabel: (state) => { + const locale = state.availableLocales.find((item) => item.value === state.currentLocale) + return locale ? locale.label : '' + } + }, + + actions: { + setLocale(locale) { + this.currentLocale = locale + i18n.global.locale.value = locale + } + }, + + persist: { + key: 'i18n-store', + storage: customStorage, + pick: ['currentLocale'] + } + } +) diff --git a/resources/admin/src/stores/modules/layout.js b/resources/admin/src/stores/modules/layout.js new file mode 100644 index 0000000..be91c8e --- /dev/null +++ b/resources/admin/src/stores/modules/layout.js @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { customStorage } from '../persist' + +export const useLayoutStore = defineStore( + 'layout', + () => { + // 布局模式:'default', 'menu', 'top' + const layoutMode = ref('default') + + // 侧边栏折叠状态 + const sidebarCollapsed = ref(false) + + // 主题颜色 + const themeColor = ref('#1890ff') + + // 显示标签栏 + const showTags = ref(true) + + // 显示面包屑 + const showBreadcrumb = ref(true) + + // 当前选中的父菜单(用于双栏布局) + const selectedParentMenu = ref(null) + + // 视图标签页(用于记录页面滚动位置) + const viewTags = ref([]) + + // 刷新标签的 key,用于触发组件刷新 + const refreshKey = ref(0) + + // 切换侧边栏折叠 + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + // 设置选中的父菜单 + const setSelectedParentMenu = (menu) => { + selectedParentMenu.value = menu + } + + // 设置布局模式 + const setLayoutMode = (mode) => { + layoutMode.value = mode + } + + // 更新视图标签 + const updateViewTags = (tag) => { + const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath) + if (index !== -1) { + viewTags.value[index] = tag + } else { + viewTags.value.push(tag) + } + } + + // 移除视图标签 + const removeViewTags = (fullPath) => { + const index = viewTags.value.findIndex((item) => item.fullPath === fullPath) + if (index !== -1) { + viewTags.value.splice(index, 1) + } + } + + // 清空视图标签 + const clearViewTags = () => { + viewTags.value = [] + } + + // 设置主题颜色 + const setThemeColor = (color) => { + themeColor.value = color + document.documentElement.style.setProperty('--primary-color', color) + } + + // 设置标签栏显示 + const setShowTags = (show) => { + showTags.value = show + document.documentElement.style.setProperty('--show-tags', show ? 'block' : 'none') + } + + // 设置面包屑显示 + const setShowBreadcrumb = (show) => { + showBreadcrumb.value = show + } + + // 刷新标签 + const refreshTag = () => { + refreshKey.value++ + } + + // 重置主题设置 + const resetTheme = () => { + themeColor.value = '#1890ff' + showTags.value = true + showBreadcrumb.value = true + document.documentElement.style.setProperty('--primary-color', '#1890ff') + document.documentElement.style.setProperty('--show-tags', 'block') + } + + return { + layoutMode, + sidebarCollapsed, + selectedParentMenu, + viewTags, + themeColor, + showTags, + showBreadcrumb, + refreshKey, + toggleSidebar, + setLayoutMode, + setSelectedParentMenu, + updateViewTags, + removeViewTags, + clearViewTags, + setThemeColor, + setShowTags, + setShowBreadcrumb, + resetTheme, + refreshTag, + } + }, + { + persist: { + key: 'layout-store', + storage: customStorage, + pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'], + }, + }, +) diff --git a/resources/admin/src/stores/modules/user.js b/resources/admin/src/stores/modules/user.js new file mode 100644 index 0000000..14e5eb9 --- /dev/null +++ b/resources/admin/src/stores/modules/user.js @@ -0,0 +1,118 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { resetRouter } from '../../router' +import { customStorage } from '../persist' +import userRoutes from '@/config/routes' + +export const useUserStore = defineStore( + 'user', + () => { + const token = ref('') + const refreshToken = ref('') + const userInfo = ref(null) + const menu = ref([]) + const permissions = ref([]) + + // 设置 token + function setToken(newToken) { + token.value = newToken + } + + // 设置 refresh token + function setRefreshToken(newRefreshToken) { + refreshToken.value = newRefreshToken + } + + // 设置用户信息 + function setUserInfo(info) { + userInfo.value = info + } + + // 设置菜单 + function setMenu(newMenu) { + const staticMenus = userRoutes || [] + + // 合并静态菜单和后端菜单 + // 如果后端菜单为空,只使用静态菜单 + // 如果后端菜单不为空,合并两个菜单,后端菜单优先 + let mergedMenus = [...staticMenus] + + if (newMenu && newMenu.length > 0) { + // 创建菜单映射,用于去重(以路径为唯一标识) + const menuMap = new Map() + + // 先添加静态菜单 + staticMenus.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 添加后端菜单,如果路径重复则覆盖 + newMenu.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 转换为数组 + mergedMenus = Array.from(menuMap.values()) + } + menu.value = mergedMenus + } + + // 获取菜单 + function getMenu() { + return menu.value + } + + // 清除菜单 + function clearMenu() { + menu.value = [] + } + + // 设置权限 + function setPermissions(data){ + permissions.value = data + } + + // 登出 + function logout() { + token.value = '' + refreshToken.value = '' + userInfo.value = null + menu.value = [] + + // 重置路由 + resetRouter() + } + + // 检查是否已登录 + function isLoggedIn() { + return !!token.value + } + + return { + token, + refreshToken, + userInfo, + menu, + setToken, + setRefreshToken, + setUserInfo, + setMenu, + getMenu, + clearMenu, + setPermissions, + logout, + isLoggedIn, + } + }, + { + persist: { + key: 'user-store', + storage: customStorage, + pick: ['token', 'refreshToken', 'userInfo', 'menu'] + } + } +) diff --git a/resources/admin/src/stores/persist.js b/resources/admin/src/stores/persist.js new file mode 100644 index 0000000..429b287 --- /dev/null +++ b/resources/admin/src/stores/persist.js @@ -0,0 +1,50 @@ +/* + * @Descripttion: Pinia 持久化存储适配器 - 使用 tool.data 封装的 localStorage + * @version: 1.0 + */ + +import tool from '@/utils/tool' + +/** + * 自定义存储适配器 + * 使用 tool.data 的 set/get/remove 方法,支持加密和过期时间 + */ +export const customStorage = { + /** + * 获取数据 + * @param {string} key - 存储键 + * @returns {any} - 存储的数据 + */ + getItem: (key) => { + return tool.data.get(key) + }, + + /** + * 设置数据 + * @param {string} key - 存储键 + * @param {any} value - 要存储的值 + */ + setItem: (key, value) => { + tool.data.set(key, value) + }, + + /** + * 删除数据 + * @param {string} key - 存储键 + */ + removeItem: (key) => { + tool.data.remove(key) + } +} + +/** + * 默认持久化配置 + */ +export const defaultPersistConfig = { + storage: customStorage, + // 可以在这里添加其他全局配置,如过期时间等 + // serializer: { + // serialize: (state) => JSON.stringify(state), + // deserialize: (value) => JSON.parse(value) + // } +} diff --git a/resources/admin/src/style.css b/resources/admin/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/resources/admin/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/resources/admin/src/utils/request.js b/resources/admin/src/utils/request.js new file mode 100644 index 0000000..3e3f9b9 --- /dev/null +++ b/resources/admin/src/utils/request.js @@ -0,0 +1,140 @@ +import axios from "axios"; +import config from "@/config"; +import { useUserStore } from "@/stores/modules/user"; +import { message } from "ant-design-vue"; +import router from "@/router"; + +const request = axios.create({ + timeout: 30000, + baseURL: config.API_URL, +}); + +// 是否正在刷新 token +let isRefreshing = false; +// 存储待重试的请求 +let requests = []; + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + const userStore = useUserStore(); + const token = userStore.token; + + // 如果有 token,添加到请求头 + if (token) { + config.headers["Authorization"] = `Bearer ${token}`; + } + + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + // 根据后端返回的数据结构进行处理 + // 后端返回格式为 { code, message, data } + const { code, data, message: msg } = response.data; + + // 请求成功 + if (code === 200 || code === 1) { + return { code, data, message: msg }; + } + + // 其他错误码处理 + message.error(msg || "请求失败"); + return Promise.reject(new Error(msg || "请求失败")); + }, + async (error) => { + const userStore = useUserStore(); + const { response } = error; + + // 无响应(网络错误、超时等) + if (!response) { + message.error("网络错误,请检查网络连接"); + return Promise.reject(error); + } + + const { status, data } = response; + + // 401 未授权 - token 过期或无效 + if (status === 401) { + // 如果正在刷新 token,将请求加入队列 + if (isRefreshing) { + return new Promise((resolve) => { + requests.push((token) => { + // 重新设置请求头 + error.config.headers["Authorization"] = + `Bearer ${token}`; + resolve(http(error.config)); + }); + }); + } + + // 标记正在刷新 + isRefreshing = true; + + try { + // 尝试刷新 token + const newToken = await refreshToken(); + + // 刷新成功,更新 token + userStore.setToken(newToken); + + // 执行队列中的所有请求 + requests.forEach((callback) => callback(newToken)); + requests = []; + + // 重新执行当前请求 + error.config.headers["Authorization"] = `Bearer ${newToken}`; + return request(error.config); + } catch (refreshError) { + // 刷新失败,清空队列并跳转登录页 + requests = []; + userStore.logout(); + router.push("/login"); + message.error("登录已过期,请重新登录"); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + // 403 禁止访问 + if (status === 403) { + message.error("没有权限访问该资源"); + return Promise.reject(error); + } + + // 404 资源不存在 + if (status === 404) { + message.error("请求的资源不存在"); + return Promise.reject(error); + } + + // 500 服务器错误 + if (status >= 500) { + message.error("服务器错误,请稍后重试"); + return Promise.reject(error); + } + + // 其他错误 + const errorMessage = data?.message || error.message || "请求失败"; + message.error(errorMessage); + return Promise.reject(error); + }, +); + +// 刷新 token 的方法 +async function refreshToken() { + // 刷新接口需要携带当前token在请求头中 + const response = await request.post('auth/refresh'); + + // 返回格式为 { code, data: { token } } + return response.data.token; +} + +export default request; diff --git a/resources/admin/src/utils/tool.js b/resources/admin/src/utils/tool.js new file mode 100644 index 0000000..1d6bf32 --- /dev/null +++ b/resources/admin/src/utils/tool.js @@ -0,0 +1,499 @@ +/* + * @Descripttion: 工具集 + * @version: 2.0 + * @LastEditors: sakuya + * @LastEditTime: 2026年1月15日 + */ + +import CryptoJS from "crypto-js"; +import sysConfig from "@/config"; + +const tool = {}; + +/** + * 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象) + * @param {*} value - 要检查的值 + * @returns {boolean} + */ +tool.isValid = function (value) { + if (value === null || value === undefined) { + return false; + } + if (typeof value === "string" && value.trim() === "") { + return false; + } + if (Array.isArray(value) && value.length === 0) { + return false; + } + if (typeof value === "object" && Object.keys(value).length === 0) { + return false; + } + return true; +}; + +/** + * 防抖函数 + * @param {Function} func - 要执行的函数 + * @param {number} wait - 等待时间(毫秒) + * @param {boolean} immediate - 是否立即执行 + * @returns {Function} + */ +tool.debounce = function (func, wait = 300, immediate = false) { + let timeout; + return function (...args) { + const context = this; + clearTimeout(timeout); + if (immediate && !timeout) { + func.apply(context, args); + } + timeout = setTimeout(() => { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }, wait); + }; +}; + +/** + * 节流函数 + * @param {Function} func - 要执行的函数 + * @param {number} wait - 等待时间(毫秒) + * @param {Object} options - 配置选项 { leading: boolean, trailing: boolean } + * @returns {Function} + */ +tool.throttle = function (func, wait = 300, options = {}) { + let timeout; + let previous = 0; + const { leading = true, trailing = true } = options; + + return function (...args) { + const context = this; + const now = Date.now(); + + if (!previous && !leading) { + previous = now; + } + + const remaining = wait - (now - previous); + + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + func.apply(context, args); + } else if (!timeout && trailing) { + timeout = setTimeout(() => { + previous = leading ? Date.now() : 0; + timeout = null; + func.apply(context, args); + }, remaining); + } + }; +}; + +/** + * 深拷贝对象(支持循环引用) + * @param {*} obj - 要拷贝的对象 + * @param {WeakMap} hash - 用于检测循环引用 + * @returns {*} + */ +tool.deepClone = function (obj, hash = new WeakMap()) { + if (obj === null || typeof obj !== "object") { + return obj; + } + + if (hash.has(obj)) { + return hash.get(obj); + } + + const clone = Array.isArray(obj) ? [] : {}; + hash.set(obj, clone); + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + clone[key] = tool.deepClone(obj[key], hash); + } + } + + return clone; +}; + +/* localStorage */ +tool.data = { + set(key, data, datetime = 0) { + //加密 + if (sysConfig.LS_ENCRYPTION == "AES") { + data = tool.crypto.AES.encrypt( + JSON.stringify(data), + sysConfig.LS_ENCRYPTION_key, + ); + } + let cacheValue = { + content: data, + datetime: + parseInt(datetime) === 0 + ? 0 + : new Date().getTime() + parseInt(datetime) * 1000, + }; + return localStorage.setItem(key, JSON.stringify(cacheValue)); + }, + get(key) { + try { + const value = JSON.parse(localStorage.getItem(key)); + if (value) { + let nowTime = new Date().getTime(); + if (nowTime > value.datetime && value.datetime != 0) { + localStorage.removeItem(key); + return null; + } + //解密 + if (sysConfig.LS_ENCRYPTION == "AES") { + value.content = JSON.parse( + tool.crypto.AES.decrypt( + value.content, + sysConfig.LS_ENCRYPTION_key, + ), + ); + } + return value.content; + } + return null; + } catch { + return null; + } + }, + remove(key) { + return localStorage.removeItem(key); + }, + clear() { + return localStorage.clear(); + }, +}; + +/*sessionStorage*/ +tool.session = { + set(table, settings) { + const _set = JSON.stringify(settings); + return sessionStorage.setItem(table, _set); + }, + get(table) { + const data = sessionStorage.getItem(table); + try { + return JSON.parse(data); + } catch { + return null; + } + }, + remove(table) { + return sessionStorage.removeItem(table); + }, + clear() { + return sessionStorage.clear(); + }, +}; + +/*cookie*/ +tool.cookie = { + /** + * 设置cookie + * @param {string} name - cookie名称 + * @param {string} value - cookie值 + * @param {Object} config - 配置选项 + */ + set(name, value, config = {}) { + const cfg = { + expires: null, + path: null, + domain: null, + secure: false, + httpOnly: false, + sameSite: "Lax", + ...config, + }; + let cookieStr = `${name}=${encodeURIComponent(value)}`; + if (cfg.expires) { + const exp = new Date(); + exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000); + cookieStr += `;expires=${exp.toUTCString()}`; + } + if (cfg.path) { + cookieStr += `;path=${cfg.path}`; + } + if (cfg.domain) { + cookieStr += `;domain=${cfg.domain}`; + } + if (cfg.secure) { + cookieStr += `;secure`; + } + if (cfg.sameSite) { + cookieStr += `;SameSite=${cfg.sameSite}`; + } + document.cookie = cookieStr; + }, + /** + * 获取cookie + * @param {string} name - cookie名称 + * @returns {string|null} + */ + get(name) { + const arr = document.cookie.match( + new RegExp("(^| )" + name + "=([^;]*)(;|$)"), + ); + if (arr != null) { + return decodeURIComponent(arr[2]); + } + return null; + }, + /** + * 删除cookie + * @param {string} name - cookie名称 + */ + remove(name) { + const exp = new Date(); + exp.setTime(exp.getTime() - 1); + document.cookie = `${name}=;expires=${exp.toUTCString()}`; + }, +}; + +/* Fullscreen */ +/** + * 切换全屏状态 + * @param {HTMLElement} element - 要全屏的元素 + */ +tool.screen = function (element) { + const isFull = !!( + document.webkitIsFullScreen || + document.mozFullScreen || + document.msFullscreenElement || + document.fullscreenElement + ); + if (isFull) { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + } else { + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } else if (element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + } + } +}; + +/* 复制对象(浅拷贝) */ +/** + * 浅拷贝对象 + * @param {*} obj - 要拷贝的对象 + * @returns {*} - 拷贝后的对象 + */ +tool.objCopy = function (obj) { + if (obj === null || typeof obj !== "object") { + return obj; + } + return JSON.parse(JSON.stringify(obj)); +}; + +/* 日期格式化 */ +/** + * 格式化日期 + * @param {Date|string|number} date - 日期对象、时间戳或日期字符串 + * @param {string} fmt - 格式化字符串,默认 "yyyy-MM-dd hh:mm:ss" + * @returns {string} - 格式化后的日期字符串 + */ +tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") { + if (!date) return ""; + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) return ""; + + const o = { + "M+": dateObj.getMonth() + 1, // 月份 + "d+": dateObj.getDate(), // 日 + "h+": dateObj.getHours(), // 小时 + "m+": dateObj.getMinutes(), // 分 + "s+": dateObj.getSeconds(), // 秒 + "q+": Math.floor((dateObj.getMonth() + 3) / 3), // 季度 + S: dateObj.getMilliseconds(), // 毫秒 + }; + if (/(y+)/.test(fmt)) { + fmt = fmt.replace( + RegExp.$1, + (dateObj.getFullYear() + "").substr(4 - RegExp.$1.length), + ); + } + for (const k in o) { + if (new RegExp("(" + k + ")").test(fmt)) { + fmt = fmt.replace( + RegExp.$1, + RegExp.$1.length == 1 + ? o[k] + : ("00" + o[k]).substr(("" + o[k]).length), + ); + } + } + return fmt; +}; + +/* 千分符 */ +/** + * 格式化数字,添加千分位分隔符 + * @param {number|string} num - 要格式化的数字 + * @param {number} decimals - 保留小数位数,默认为0 + * @returns {string} - 格式化后的字符串 + */ +tool.groupSeparator = function (num, decimals = 0) { + if (num === null || num === undefined || num === "") return ""; + const numStr = Number(num).toFixed(decimals); + const parts = numStr.split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return parts.join("."); +}; + +/* 常用加解密 */ +tool.crypto = { + //MD5加密 + MD5(data) { + return CryptoJS.MD5(data).toString(); + }, + //BASE64加解密 + BASE64: { + encrypt(data) { + return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data)); + }, + decrypt(cipher) { + return CryptoJS.enc.Base64.parse(cipher).toString( + CryptoJS.enc.Utf8, + ); + }, + }, + //AES加解密 + AES: { + encrypt(data, secretKey, config = {}) { + if (secretKey.length % 8 != 0) { + console.warn( + "[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。", + ); + } + const result = CryptoJS.AES.encrypt( + data, + CryptoJS.enc.Utf8.parse(secretKey), + { + iv: CryptoJS.enc.Utf8.parse(config.iv || ""), + mode: CryptoJS.mode[config.mode || "ECB"], + padding: CryptoJS.pad[config.padding || "Pkcs7"], + }, + ); + return result.toString(); + }, + decrypt(cipher, secretKey, config = {}) { + const result = CryptoJS.AES.decrypt( + cipher, + CryptoJS.enc.Utf8.parse(secretKey), + { + iv: CryptoJS.enc.Utf8.parse(config.iv || ""), + mode: CryptoJS.mode[config.mode || "ECB"], + padding: CryptoJS.pad[config.padding || "Pkcs7"], + }, + ); + return CryptoJS.enc.Utf8.stringify(result); + }, + }, +}; + +/* 树形数据转扁平数组 */ +/** + * 将树形结构转换为扁平数组 + * @param {Array} tree - 树形数组 + * @param {Object} config - 配置项 { children: "children" } + * @returns {Array} - 扁平化后的数组 + */ +tool.treeToList = function (tree, config = { children: "children" }) { + const result = []; + tree.forEach((item) => { + const tmp = { ...item }; + const childrenKey = config.children || "children"; + + if (tmp[childrenKey] && tmp[childrenKey].length > 0) { + result.push({ ...item }); + const childrenRoutes = tool.treeToList(tmp[childrenKey], config); + result.push(...childrenRoutes); + } else { + result.push(tmp); + } + }); + return result; +}; + +/* 获取父节点数据(保留原有函数名) */ +/** + * 根据ID获取父节点数据 + * @param {Array} list - 数据列表 + * @param {number|string} targetId - 目标ID + * @param {Object} config - 配置项 { pid: "parent_id", idField: "id", field: [] } + * @returns {*} - 父节点数据或指定字段 + */ +tool.get_parents = function ( + list, + targetId = 0, + config = { pid: "parent_id", idField: "id", field: [] }, +) { + let res = null; + list.forEach((item) => { + if (item[config.idField || "id"] === targetId) { + if (config.field && config.field.length > 1) { + res = {}; + config.field.forEach((field) => { + res[field] = item[field]; + }); + } else if (config.field && config.field.length === 1) { + res = item[config.field[0]]; + } else { + res = item; + } + } + }); + return res; +}; + +/* 获取数据字段 */ +/** + * 从数据对象中提取指定字段 + * @param {Object} data - 数据对象 + * @param {Array} fields - 字段名数组 + * @returns {*} - 提取的字段数据 + */ +tool.getDataField = function (data, fields = []) { + if (!data || typeof data !== "object") { + return data; + } + if (fields.length === 0) { + return data; + } + if (fields.length === 1) { + return data[fields[0]]; + } else { + const result = {}; + fields.forEach((field) => { + result[field] = data[field]; + }); + return result; + } +}; + +// 兼容旧函数名 +tool.tree_to_list = tool.treeToList; +tool.get_data_field = tool.getDataField; + +export default tool; diff --git a/resources/admin/src/utils/websocket.js b/resources/admin/src/utils/websocket.js new file mode 100644 index 0000000..f0cbc7b --- /dev/null +++ b/resources/admin/src/utils/websocket.js @@ -0,0 +1,257 @@ +/** + * WebSocket Client Helper + * + * Provides a simple interface for WebSocket connections + */ + +class WebSocketClient { + constructor(url, options = {}) { + this.url = url + this.ws = null + this.reconnectAttempts = 0 + this.maxReconnectAttempts = options.maxReconnectAttempts || 5 + this.reconnectInterval = options.reconnectInterval || 3000 + this.reconnectDelay = options.reconnectDelay || 1000 + this.heartbeatInterval = options.heartbeatInterval || 30000 + this.heartbeatTimer = null + this.isManualClose = false + this.isConnecting = false + + // Event handlers + this.onOpen = options.onOpen || null + this.onMessage = options.onMessage || null + this.onError = options.onError || null + this.onClose = options.onClose || null + + // Message handlers + this.messageHandlers = new Map() + } + + /** + * Connect to WebSocket server + */ + connect() { + if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { + return + } + + this.isConnecting = true + this.isManualClose = false + + try { + this.ws = new WebSocket(this.url) + + this.ws.onopen = (event) => { + console.log('WebSocket connected', event) + this.isConnecting = false + this.reconnectAttempts = 0 + + // Start heartbeat + this.startHeartbeat() + + // Call onOpen handler + if (this.onOpen) { + this.onOpen(event) + } + } + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) + console.log('WebSocket message received', message) + + // Handle different message types + this.handleMessage(message) + + // Call onMessage handler + if (this.onMessage) { + this.onMessage(message, event) + } + } catch (error) { + console.error('Failed to parse WebSocket message', error) + } + } + + this.ws.onerror = (error) => { + console.error('WebSocket error', error) + this.isConnecting = false + + // Stop heartbeat + this.stopHeartbeat() + + // Call onError handler + if (this.onError) { + this.onError(error) + } + } + + this.ws.onclose = (event) => { + console.log('WebSocket closed', event) + this.isConnecting = false + + // Stop heartbeat + this.stopHeartbeat() + + // Call onClose handler + if (this.onClose) { + this.onClose(event) + } + + // Attempt to reconnect if not manually closed + if (!this.isManualClose && this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnect() + } + } + } catch (error) { + console.error('Failed to create WebSocket connection', error) + this.isConnecting = false + + // Call onError handler + if (this.onError) { + this.onError(error) + } + } + } + + /** + * Reconnect to WebSocket server + */ + reconnect() { + if (this.isConnecting || this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('Max reconnection attempts reached') + return + } + + this.reconnectAttempts++ + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + + console.log(`Reconnecting attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`) + + setTimeout(() => { + this.connect() + }, delay) + } + + /** + * Disconnect from WebSocket server + */ + disconnect() { + this.isManualClose = true + this.stopHeartbeat() + + if (this.ws) { + this.ws.close() + this.ws = null + } + } + + /** + * Send message to server + */ + send(type, data = {}) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = JSON.stringify({ + type, + data + }) + this.ws.send(message) + console.log('WebSocket message sent', { type, data }) + } else { + console.warn('WebSocket is not connected') + } + } + + /** + * Handle incoming messages + */ + handleMessage(message) { + const { type, data } = message + + // Get handler for this message type + const handler = this.messageHandlers.get(type) + if (handler) { + handler(data) + } + } + + /** + * Register message handler + */ + on(messageType, handler) { + this.messageHandlers.set(messageType, handler) + } + + /** + * Unregister message handler + */ + off(messageType) { + this.messageHandlers.delete(messageType) + } + + /** + * Start heartbeat + */ + startHeartbeat() { + this.stopHeartbeat() + this.heartbeatTimer = setInterval(() => { + this.send('heartbeat', { timestamp: Date.now() }) + }, this.heartbeatInterval) + } + + /** + * Stop heartbeat + */ + stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + } + + /** + * Get connection state + */ + get readyState() { + if (!this.ws) return WebSocket.CLOSED + return this.ws.readyState + } + + /** + * Check if connected + */ + get isConnected() { + return this.ws && this.ws.readyState === WebSocket.OPEN + } +} + +/** + * Create WebSocket connection + */ +export function createWebSocket(userId, token, options = {}) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}` + + return new WebSocketClient(url, options) +} + +/** + * WebSocket singleton instance + */ +let wsClient = null + +export function getWebSocket(userId, token, options = {}) { + if (!wsClient || !wsClient.isConnected) { + wsClient = createWebSocket(userId, token, options) + } + return wsClient +} + +export function closeWebSocket() { + if (wsClient) { + wsClient.disconnect() + wsClient = null + } +} + +export default WebSocketClient diff --git a/resources/admin/vite.config.js b/resources/admin/vite.config.js new file mode 100644 index 0000000..f7317b4 --- /dev/null +++ b/resources/admin/vite.config.js @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, +}) diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..23a6be2 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,426 @@ + + + + + + + Laravel-S - 高性能后端 API 系统 + + + +
+ +
+ +
+
+

高性能后端 API 系统

+

基于 Laravel + Laravel-S + Swoole 构建的现代化后端 API 系统,提供卓越的性能和开发效率

+
+ 进入后台管理 + 查看文档 +
+
+
+ +
+
+

核心特性

+
+
+
+

高性能

+

基于 Swoole 协程框架,提供卓越的并发处理能力,轻松应对高并发场景

+
+
+
🧩
+

模块化

+

采用 Laravel Modules 实现模块化架构,业务模块独立管理,易于扩展和维护

+
+
+
🔒
+

安全可靠

+

JWT 认证、RBAC 权限控制、数据验证等多重安全防护机制

+
+
+
📊
+

完整后台

+

基于 Vue3 + Ant Design Vue 构建的现代化后台管理系统

+
+
+
🔄
+

热重载

+

开发环境支持文件监控热重载,提升开发体验

+
+
+
📝
+

RESTful API

+

遵循 RESTful 规范的 API 设计,统一的响应格式

+
+
+
+
+ +
+
+

技术栈

+
+
+
🐘
+

PHP

+
+
+
🔷
+

Laravel

+
+
+
🚀
+

Swoole

+
+
+
+

Laravel-S

+
+
+
🔑
+

JWT-Auth

+
+
+
📦
+

Laravel Modules

+
+
+
💚
+

MySQL

+
+
+
🔴
+

Redis

+
+
+
+
+ +
+
+

系统架构

+
+
+

模块化设计

+

项目采用清晰的分层架构,将业务逻辑合理划分:

+
    +
  • 基础模块(Auth、System):不使用 Laravel Modules 扩展
  • +
  • 业务模块:使用 Laravel Modules 独立管理
  • +
  • Controller 层:处理 HTTP 请求
  • +
  • Service 层:业务逻辑处理
  • +
  • Model 层:数据模型定义
  • +
  • 统一的 API 响应格式
  • +
+
+
+
+

快速开始

+
# 安装依赖
+composer install
+
+# 配置环境
+cp .env.example .env
+
+# 执行迁移
+php artisan migrate
+
+# 启动 Laravel-S
+php bin/laravels start
+
+# 访问后台
+# http://localhost:8000/admin
+
+
+
+
+
+ + + + diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..86f52f0 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,188 @@ +group(function () { + // 认证相关 + Route::prefix('auth')->group(function () { + Route::post('/logout', [\App\Http\Controllers\Auth\Admin\Auth::class, 'logout']); + Route::post('/refresh', [\App\Http\Controllers\Auth\Admin\Auth::class, 'refresh']); + Route::get('/me', [\App\Http\Controllers\Auth\Admin\Auth::class, 'me']); + Route::post('/change-password', [\App\Http\Controllers\Auth\Admin\Auth::class, 'changePassword']); + }); + + // 用户管理 + Route::prefix('users')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\User::class, 'index']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\User::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\User::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\User::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\User::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\User::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\User::class, 'batchUpdateStatus']); + Route::post('/batch-department', [\App\Http\Controllers\Auth\Admin\User::class, 'batchAssignDepartment']); + Route::post('/batch-roles', [\App\Http\Controllers\Auth\Admin\User::class, 'batchAssignRoles']); + Route::post('/export', [\App\Http\Controllers\Auth\Admin\User::class, 'export']); + Route::post('/import', [\App\Http\Controllers\Auth\Admin\User::class, 'import']); + Route::get('/download-template', [\App\Http\Controllers\Auth\Admin\User::class, 'downloadTemplate']); + }); + + // 角色管理 + Route::prefix('roles')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\Role::class, 'index']); + Route::get('/all', [\App\Http\Controllers\Auth\Admin\Role::class, 'getAll']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\Role::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\Role::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\Role::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\Role::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\Role::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\Role::class, 'batchUpdateStatus']); + Route::post('/{id}/permissions', [\App\Http\Controllers\Auth\Admin\Role::class, 'assignPermissions']); + Route::get('/{id}/permissions', [\App\Http\Controllers\Auth\Admin\Role::class, 'getPermissions']); + Route::post('/{id}/copy', [\App\Http\Controllers\Auth\Admin\Role::class, 'copy']); + Route::post('/batch-copy', [\App\Http\Controllers\Auth\Admin\Role::class, 'batchCopy']); + }); + + // 权限管理 + Route::prefix('permissions')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\Permission::class, 'index']); + Route::get('/tree', [\App\Http\Controllers\Auth\Admin\Permission::class, 'tree']); + Route::get('/menu', [\App\Http\Controllers\Auth\Admin\Permission::class, 'menu']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\Permission::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\Permission::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\Permission::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\Permission::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\Permission::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\Permission::class, 'batchUpdateStatus']); + }); + + // 部门管理 + Route::prefix('departments')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\Department::class, 'index']); + Route::get('/tree', [\App\Http\Controllers\Auth\Admin\Department::class, 'tree']); + Route::get('/all', [\App\Http\Controllers\Auth\Admin\Department::class, 'getAll']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\Department::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\Department::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\Department::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\Department::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\Department::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\Department::class, 'batchUpdateStatus']); + Route::post('/export', [\App\Http\Controllers\Auth\Admin\Department::class, 'export']); + Route::post('/import', [\App\Http\Controllers\Auth\Admin\Department::class, 'import']); + Route::get('/download-template', [\App\Http\Controllers\Auth\Admin\Department::class, 'downloadTemplate']); + }); + + // 在线用户管理 + Route::prefix('online-users')->group(function () { + Route::get('/count', [\App\Http\Controllers\Auth\Admin\User::class, 'getOnlineCount']); + Route::get('/', [\App\Http\Controllers\Auth\Admin\User::class, 'getOnlineUsers']); + Route::get('/{userId}/sessions', [\App\Http\Controllers\Auth\Admin\User::class, 'getUserSessions']); + Route::post('/{userId}/offline', [\App\Http\Controllers\Auth\Admin\User::class, 'setUserOffline']); + Route::post('/{userId}/offline-all', [\App\Http\Controllers\Auth\Admin\User::class, 'setUserAllOffline']); + }); + + // 系统配置管理 + Route::prefix('configs')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Config::class, 'index']); + Route::get('/all', [\App\Http\Controllers\System\Admin\Config::class, 'getByGroup']); + Route::get('/groups', [\App\Http\Controllers\System\Admin\Config::class, 'getGroups']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Config::class, 'show']); + Route::post('/', [\App\Http\Controllers\System\Admin\Config::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Config::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Config::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Config::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Config::class, 'batchUpdateStatus']); + }); + + // 系统操作日志 + Route::prefix('logs')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Log::class, 'index']); + Route::get('/statistics', [\App\Http\Controllers\System\Admin\Log::class, 'getStatistics']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'show']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Log::class, 'batchDelete']); + Route::post('/clear', [\App\Http\Controllers\System\Admin\Log::class, 'clearLogs']); + Route::post('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']); + }); + + // 数据字典管理 + Route::prefix('dictionaries')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'index']); + Route::get('/all', [\App\Http\Controllers\System\Admin\Dictionary::class, 'all']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'show']); + Route::post('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchUpdateStatus']); + }); + + // 数据字典项管理 + Route::prefix('dictionary-items')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'getItemsList']); + Route::post('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'storeItem']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'updateItem']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'destroyItem']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchDeleteItems']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchUpdateItemsStatus']); + }); + + // 任务管理 + Route::prefix('tasks')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Task::class, 'index']); + Route::get('/all', [\App\Http\Controllers\System\Admin\Task::class, 'all']); + Route::get('/statistics', [\App\Http\Controllers\System\Admin\Task::class, 'getStatistics']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Task::class, 'show']); + Route::post('/', [\App\Http\Controllers\System\Admin\Task::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Task::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Task::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Task::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Task::class, 'batchUpdateStatus']); + Route::post('/{id}/run', [\App\Http\Controllers\System\Admin\Task::class, 'run']); + }); + + // 城市数据管理 + Route::prefix('cities')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\City::class, 'index']); + Route::get('/tree', [\App\Http\Controllers\System\Admin\City::class, 'tree']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\City::class, 'show']); + Route::get('/{id}/children', [\App\Http\Controllers\System\Admin\City::class, 'children']); + Route::get('/provinces', [\App\Http\Controllers\System\Admin\City::class, 'provinces']); + Route::get('/{provinceId}/cities', [\App\Http\Controllers\System\Admin\City::class, 'cities']); + Route::get('/{cityId}/districts', [\App\Http\Controllers\System\Admin\City::class, 'districts']); + Route::post('/', [\App\Http\Controllers\System\Admin\City::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\City::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\City::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\City::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\City::class, 'batchUpdateStatus']); + }); + + // 文件上传管理 + Route::prefix('upload')->group(function () { + Route::post('/', [\App\Http\Controllers\System\Admin\Upload::class, 'upload']); + Route::post('/multiple', [\App\Http\Controllers\System\Admin\Upload::class, 'uploadMultiple']); + Route::post('/base64', [\App\Http\Controllers\System\Admin\Upload::class, 'uploadBase64']); + Route::post('/delete', [\App\Http\Controllers\System\Admin\Upload::class, 'delete']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Upload::class, 'batchDelete']); + }); + + // WebSocket 管理 + Route::prefix('websocket')->group(function () { + Route::get('/online-count', [\App\Http\Controllers\System\WebSocket::class, 'getOnlineCount']); + Route::get('/online-users', [\App\Http\Controllers\System\WebSocket::class, 'getOnlineUsers']); + Route::post('/check-online', [\App\Http\Controllers\System\WebSocket::class, 'checkOnline']); + Route::post('/send-to-user', [\App\Http\Controllers\System\WebSocket::class, 'sendToUser']); + Route::post('/send-to-users', [\App\Http\Controllers\System\WebSocket::class, 'sendToUsers']); + Route::post('/broadcast', [\App\Http\Controllers\System\WebSocket::class, 'broadcast']); + Route::post('/send-to-channel', [\App\Http\Controllers\System\WebSocket::class, 'sendToChannel']); + Route::post('/send-notification', [\App\Http\Controllers\System\WebSocket::class, 'sendNotification']); + Route::post('/send-notification-to-users', [\App\Http\Controllers\System\WebSocket::class, 'sendNotificationToUsers']); + Route::post('/push-data-update', [\App\Http\Controllers\System\WebSocket::class, 'pushDataUpdate']); + Route::post('/push-data-update-channel', [\App\Http\Controllers\System\WebSocket::class, 'pushDataUpdateToChannel']); + Route::post('/disconnect-user', [\App\Http\Controllers\System\WebSocket::class, 'disconnectUser']); + }); +}); diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..7c4d255 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,3 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});