Zory 3 semanas atrás
commit
65ef65b789
63 arquivos alterados com 9976 adições e 0 exclusões
  1. 12 0
      .editorconfig
  2. 6 0
      .env.development
  3. 5 0
      .env.production
  4. 2 0
      .gitignore
  5. 13 0
      index.html
  6. 29 0
      package.json
  7. BIN
      public/favicon.ico
  8. BIN
      public/mono-admin-logo.png
  9. 30 0
      src/App.vue
  10. 142 0
      src/api/auth.js
  11. 52 0
      src/api/banner.js
  12. 46 0
      src/api/comment.js
  13. 17 0
      src/api/config.js
  14. 155 0
      src/api/content.js
  15. 82 0
      src/api/dashboard.js
  16. 59 0
      src/api/dict.js
  17. 37 0
      src/api/logs.js
  18. 29 0
      src/api/storage.js
  19. 19 0
      src/api/user.js
  20. 11 0
      src/assets/storage/README.md
  21. 1 0
      src/assets/storage/alibabacloud-color.svg
  22. 1 0
      src/assets/storage/qiniu-color.svg
  23. 0 0
      src/assets/storage/tencentcloud-color.svg
  24. 35 0
      src/components/AgreementEditor.vue
  25. 110 0
      src/components/DataList.vue
  26. 142 0
      src/components/SearchForm.vue
  27. 38 0
      src/layout/components/Breadcrumb.vue
  28. 25 0
      src/layout/components/Footer.vue
  29. 173 0
      src/layout/components/Header.vue
  30. 367 0
      src/layout/components/Sidebar.vue
  31. 59 0
      src/layout/index.vue
  32. 32 0
      src/main.js
  33. 71 0
      src/router/index.js
  34. 42 0
      src/stores/menu.js
  35. 47 0
      src/stores/user.js
  36. 4 0
      src/styles/theme.module.less
  37. 44 0
      src/styles/variables.less
  38. 165 0
      src/utils/date.js
  39. 15 0
      src/utils/listParams.js
  40. 55 0
      src/utils/request.js
  41. 12 0
      src/utils/utils.js
  42. 82 0
      src/views/404.vue
  43. 723 0
      src/views/Dashboard.vue
  44. 441 0
      src/views/Login.vue
  45. 252 0
      src/views/Profile.vue
  46. 505 0
      src/views/Settings.vue
  47. 63 0
      src/views/config/EmailConfig.vue
  48. 213 0
      src/views/config/MnpWechat.vue
  49. 315 0
      src/views/content/Category.vue
  50. 526 0
      src/views/content/Post.vue
  51. 380 0
      src/views/content/Reply.vue
  52. 536 0
      src/views/content/Topic.vue
  53. 83 0
      src/views/platform/Agreement.vue
  54. 655 0
      src/views/platform/Banner.vue
  55. 114 0
      src/views/platform/User.vue
  56. 507 0
      src/views/storage/Config.vue
  57. 250 0
      src/views/storage/Files.vue
  58. 676 0
      src/views/system/Dict.vue
  59. 427 0
      src/views/system/Logs.vue
  60. 467 0
      src/views/system/Permission.vue
  61. 227 0
      src/views/system/Role.vue
  62. 326 0
      src/views/system/User.vue
  63. 24 0
      vite.config.js

+ 12 - 0
.editorconfig

@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false

+ 6 - 0
.env.development

@@ -0,0 +1,6 @@
+# 开发环境配置
+NODE_ENV=development
+
+# API基础地址
+VITE_API_BASE_URL=http://localhost:1787/api/
+# VITE_API_BASE_URL=https://t.meoyun.com/adminapi/

+ 5 - 0
.env.production

@@ -0,0 +1,5 @@
+# 生产环境配置
+
+# API基础地址
+VITE_API_BASE_URL=/adminapi/
+

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/node_modules
+/dist

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>云喵圈子</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 29 - 0
package.json

@@ -0,0 +1,29 @@
+{
+  "name": "admin-system",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite --mode development",
+    "build": "vite build --mode production",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@ant-design/icons-vue": "^7.0.1",
+    "@wangeditor/editor": "5.1.23",
+    "@wangeditor/editor-for-vue": "5.1.12",
+    "ant-design-vue": "^4.2.6",
+    "axios": "^1.10.0",
+    "dayjs": "^1.11.13",
+    "echarts": "^5.6.0",
+    "pinia": "^3.0.3",
+    "pinia-plugin-persistedstate": "^4.4.1",
+    "vue": "^3.5.17",
+    "vue-router": "^4.5.1"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.0",
+    "less": "^4.4.0",
+    "vite": "^7.0.5"
+  }
+}

BIN
public/favicon.ico


BIN
public/mono-admin-logo.png


+ 30 - 0
src/App.vue

@@ -0,0 +1,30 @@
+<template>
+  <div id="app">
+    <a-config-provider :locale="locale" :theme="themeConfig">
+      <router-view />
+    </a-config-provider>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import zhCN from 'ant-design-vue/es/locale/zh_CN'
+import themeColors from '@/styles/theme.module.less'
+
+// 设置中文语言
+const locale = ref(zhCN)
+// Ant Design Vue 4 使用运行时主题,与业务 Less 共用主色。
+const themeConfig = { token: { colorPrimary: themeColors.primaryColor } }
+</script>
+
+<style lang="less">
+:root {
+  --meow-color-primary: @primary-color;
+  --meow-color-primary-active: @primary-active-color;
+  --meow-color-primary-light: @primary-light-color;
+}
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  background-color: #fafafa;
+}
+</style>

+ 142 - 0
src/api/auth.js

@@ -0,0 +1,142 @@
+import request from '@/utils/request'
+
+export const login = (data) => {
+    return request({
+        url: 'auth/login',
+        method: 'post',
+        data
+    })
+}
+
+export const outLogin = (token) => {
+    return request({
+        url: 'auth/logout',
+        method: 'post',
+        // 本地会话会先清理,远端注销仍必须携带退出前的令牌。
+        headers: { authorization: token },
+        sessionToken: token
+    })
+}
+
+export const getInfo = (data) => {
+    return request({
+        url: 'auth/getInfo',
+        method: 'post',
+        data
+    })
+}
+
+export const getUserMenus = (data) => {
+    return request({
+        url: 'auth/getMenus',
+        method: 'get',
+        data
+    })
+}
+
+export const getAllMenus = (data) => {
+    return request({
+        url: 'auth/getAllMenus',
+        method: 'get',
+        data
+    })
+}
+
+export const saveMenus = (data) => {
+    return request({
+        url: 'auth/saveMenus',
+        method: 'post',
+        data
+    })
+}
+
+export const deleteMenus = (data) => {
+    return request({
+        url: 'auth/deleteMenus',
+        method: 'post',
+        data
+    })
+}
+
+// 角色管理相关接口
+export const getRoles = (data) => {
+    return request({
+        url: 'auth/getRoles',
+        method: 'get',
+        params: data
+    })
+}
+
+export const saveRole = (data) => {
+    return request({
+        url: 'auth/saveRole',
+        method: 'post',
+        data
+    })
+}
+
+export const deleteRole = (data) => {
+    return request({
+        url: 'auth/deleteRole',
+        method: 'post',
+        data
+    })
+}
+
+export const getRoleMenus = (data) => {
+    return request({
+        url: 'auth/getRoleMenus',
+        method: 'get',
+        params: data
+    })
+}
+
+export const saveRolePermission = (data) => {
+    return request({
+        url: 'auth/saveRolePermission',
+        method: 'post',
+        data
+    })
+}
+
+// 用户管理相关接口
+export const getUsers = (data) => {
+    return request({
+        url: 'auth/getUsers',
+        method: 'get',
+        params: data
+    })
+}
+
+export const saveUser = (data) => {
+    return request({
+        url: 'auth/saveUser',
+        method: 'post',
+        data
+    })
+}
+
+export const deleteUser = (data) => {
+    return request({
+        url: 'auth/deleteUser',
+        method: 'post',
+        data
+    })
+}
+
+// 个人信息管理相关接口
+export const updateProfile = (data) => {
+    return request({
+        url: 'auth/updateProfile',
+        method: 'post',
+        data
+    })
+}
+
+export const changePassword = (data) => {
+    return request({
+        url: 'auth/changePassword',
+        method: 'post',
+        data
+    })
+}

+ 52 - 0
src/api/banner.js

@@ -0,0 +1,52 @@
+import request from '@/utils/request'
+
+// 广告管理相关API
+export const bannerApi = {
+  // 统一分页列表方法
+  getListByPage(params) {
+    return request({
+      url: 'link/getListByPage',
+      method: 'get',
+      params
+    })
+  },
+
+// 创建广告
+  createBanner(data) {
+    return request({
+      url: 'link/createBanner',
+      method: 'post',
+      data
+    })
+  },
+
+  // 更新广告
+  updateBanner(data) {
+    return request({
+      url: 'link/updateBanner',
+      method: 'post',
+      data
+    })
+  },
+
+  // 删除广告
+  deleteBanner(data) {
+    return request({
+      url: 'link/deleteBanner',
+      method: 'post',
+      data
+    })
+  },
+
+  // 批量删除广告
+  batchDeleteBanners(data) {
+    return request({
+      url: 'link/batchDeleteBanners',
+      method: 'post',
+      data
+    })
+  }
+}
+
+// 导出默认API
+export default bannerApi

+ 46 - 0
src/api/comment.js

@@ -0,0 +1,46 @@
+import request from '@/utils/request'
+
+// 统一分页列表方法
+export function getListByPage(params) {
+  return request({
+    url: 'comment/getListByPage',
+    method: 'get',
+    params
+  })
+}
+
+// 更新评论
+export function updateComment(data) {
+  return request({
+    url: 'comment/updateComment',
+    method: 'post',
+    data
+  })
+}
+
+// 删除评论
+export function deleteComment(data) {
+  return request({
+    url: 'comment/deleteComment',
+    method: 'post',
+    data
+  })
+}
+
+// 批量删除评论
+export function batchDeleteComments(data) {
+  return request({
+    url: 'comment/batchDeleteComments',
+    method: 'post',
+    data
+  })
+}
+
+// 更新评论状态
+export function updateCommentStatus(data) {
+  return request({
+    url: 'comment/updateCommentStatus',
+    method: 'post',
+    data
+  })
+}

+ 17 - 0
src/api/config.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export const getPlatformConfig = (params) => {
+    return request({
+        url: 'platformConfig/getPlatformConfig',
+        method: 'get',
+        params
+    })
+}
+
+export const saveConfig = (data) => {
+    return request({
+        url: 'platformConfig/saveConfig',
+        method: 'post',
+        data
+    })
+}

+ 155 - 0
src/api/content.js

@@ -0,0 +1,155 @@
+import request from '@/utils/request'
+
+// 帖子管理相关API
+export const postApi = {
+  closePoll(data) {
+    return request({ url: '/poll/close', method: 'post', data })
+  },
+
+  // 统一分页列表方法
+  getListByPage(params) {
+    return request({
+      url: '/post/getListByPage',
+      method: 'get',
+      params
+    })
+  },
+
+  // 获取帖子详情
+  getPostDetail(params) {
+    return request({
+      url: '/post/getPostDetail',
+      method: 'get',
+      params
+    })
+  },
+
+  // 删除帖子
+  deletePost(data) {
+    return request({
+      url: '/post/deletePost',
+      method: 'post',
+      data
+    })
+  },
+
+  // 批量删除帖子
+  batchDeletePosts(data) {
+    return request({
+      url: '/post/batchDeletePosts',
+      method: 'post',
+      data
+    })
+  },
+
+
+
+}
+
+// 圈子管理相关API
+export const topicApi = {
+
+  // 统一分页列表方法
+  getListByPage(params) {
+    return request({
+      url: '/topic/getListByPage',
+      method: 'get',
+      params
+    })
+  },
+
+  // 获取圈子详情
+  getTopicDetail(params) {
+    return request({
+      url: '/topic/getTopicDetail',
+      method: 'get',
+      params
+    })
+  },
+
+  // 更新圈子状态
+  updateTopicStatus(data) {
+    return request({
+      url: '/topic/updateTopicStatus',
+      method: 'post',
+      data
+    })
+  },
+
+  // 删除圈子
+  deleteTopic(data) {
+    return request({
+      url: '/topic/deleteTopic',
+      method: 'post',
+      data
+    })
+  },
+
+  // 批量删除圈子
+  batchDeleteTopics(data) {
+    return request({
+      url: '/topic/batchDeleteTopics',
+      method: 'post',
+      data
+    })
+  },
+
+  // 保存圈子信息(编辑)
+  saveTopic(data) {
+    return request({
+      url: '/topic/saveTopic',
+      method: 'post',
+      data
+    })
+  },
+
+}
+
+// 圈子类目管理相关API
+export const categoryApi = {
+
+  // 分页获取类目列表
+  getListByPage(params) {
+    return request({
+      url: '/category/getListByPage',
+      method: 'get',
+      params
+    })
+  },
+
+// 创建类目
+  createCategory(data) {
+    return request({
+      url: '/category/createCategory',
+      method: 'post',
+      data
+    })
+  },
+
+  // 更新类目
+  updateCategory(data) {
+    return request({
+      url: '/category/updateCategory',
+      method: 'post',
+      data
+    })
+  },
+
+  // 删除类目
+  deleteCategory(data) {
+    return request({
+      url: '/category/deleteCategory',
+      method: 'post',
+      data
+    })
+  },
+
+  // 批量删除类目
+  batchDeleteCategories(data) {
+    return request({
+      url: '/category/batchDeleteCategories',
+      method: 'post',
+      data
+    })
+  }
+}

+ 82 - 0
src/api/dashboard.js

@@ -0,0 +1,82 @@
+import request from '@/utils/request'
+
+/**
+ * 获取仪表盘统计数据
+ */
+export const getStatistics = () => {
+    return request({
+        url: 'dashboard/getStatistics',
+        method: 'get'
+    })
+}
+
+/**
+ * 获取最近操作日志
+ */
+export const getRecentLogs = (params) => {
+    return request({
+        url: 'dashboard/getRecentLogs',
+        method: 'get',
+        params
+    })
+}
+
+/**
+ * 获取用户增长趋势
+ */
+export const getUserGrowth = () => {
+    return request({
+        url: 'dashboard/getUserGrowth',
+        method: 'get'
+    })
+}
+
+/**
+ * 获取系统信息
+ */
+export const getSystemInfo = () => {
+    return request({
+        url: 'dashboard/getSystemInfo',
+        method: 'get'
+    })
+}
+
+/**
+ * 获取用户分布统计
+ */
+export const getUserDistribution = () => {
+    return request({
+        url: 'dashboard/getUserDistribution',
+        method: 'get'
+    })
+}
+
+/**
+ * 获取用户统计数据
+ */
+export const getUserStatistics = () => {
+    return request({
+        url: 'dashboard/getUserStatistics',
+        method: 'get'
+    })
+}
+
+/**
+ * 获取帖子统计数据
+ */
+export const getPostStatistics = () => {
+    return request({
+        url: 'dashboard/getPostStatistics',
+        method: 'get'
+    })
+}
+
+/**
+ * 获取圈子统计数据
+ */
+export const getTopicStatistics = () => {
+    return request({
+        url: 'dashboard/getTopicStatistics',
+        method: 'get'
+    })
+}

+ 59 - 0
src/api/dict.js

@@ -0,0 +1,59 @@
+import request from '@/utils/request'
+
+// 字典类型管理
+export const getDictTypes = (params) => {
+    return request({
+        url: 'dict/getDictTypes',
+        method: 'get',
+        params
+    })
+}
+
+export const saveDictType = (data) => {
+    return request({
+        url: 'dict/saveDictType',
+        method: 'post',
+        data
+    })
+}
+
+export const deleteDictType = (data) => {
+    return request({
+        url: 'dict/deleteDictType',
+        method: 'post',
+        data
+    })
+}
+
+// 字典数据管理
+export const getDictData = (params) => {
+    return request({
+        url: 'dict/getDictData',
+        method: 'get',
+        params
+    })
+}
+
+export const saveDictData = (data) => {
+    return request({
+        url: 'dict/saveDictData',
+        method: 'post',
+        data
+    })
+}
+
+export const deleteDictData = (data) => {
+    return request({
+        url: 'dict/deleteDictData',
+        method: 'post',
+        data
+    })
+}
+
+// 获取所有字典类型(下拉选择用)
+export const getAllDictTypes = () => {
+    return request({
+        url: 'dict/getAllDictTypes',
+        method: 'get'
+    })
+}

+ 37 - 0
src/api/logs.js

@@ -0,0 +1,37 @@
+import request from '@/utils/request'
+
+// 统一分页列表方法
+export const getListByPage = (params) => {
+    return request({
+        url: 'operationLog/getListByPage',
+        method: 'get',
+        params
+    })
+}
+
+// 获取日志详情
+export const getLogDetail = (params) => {
+    return request({
+        url: 'operationLog/getLogDetail',
+        method: 'get',
+        params
+    })
+}
+
+// 清理日志
+export const clearLogs = (data) => {
+    return request({
+        url: 'operationLog/clearLogs',
+        method: 'post',
+        data
+    })
+}
+
+// 获取统计数据
+export const getStatistics = (params) => {
+    return request({
+        url: 'operationLog/getStatistics',
+        method: 'get',
+        params
+    })
+}

+ 29 - 0
src/api/storage.js

@@ -0,0 +1,29 @@
+import request from '@/utils/request'
+
+export const getStorageConfigs = () => request({ url: 'storageConfig/getList', method: 'get' })
+export const saveStorageConfig = data => request({ url: 'storageConfig/save', method: 'post', data })
+export const testStorageConfig = id => request({ url: 'storageConfig/test', method: 'post', data: { id }, timeout: 20000 })
+
+export const getEnabledStorages = () => request({ url: 'cloudFile/getStorages', method: 'get' })
+export const getCloudFiles = params => request({ url: 'cloudFile/getList', method: 'get', params, timeout: 20000 })
+export const createCloudDirectory = data => request({ url: 'cloudFile/createDirectory', method: 'post', data, timeout: 20000 })
+export const prepareCloudUpload = data => request({ url: 'cloudFile/prepareUpload', method: 'post', data, timeout: 20000 })
+export const confirmCloudUpload = fileId => request({ url: 'cloudFile/confirmUpload', method: 'post', data: { file_id: fileId }, timeout: 20000 })
+export const uploadBusinessFile = (file, scope, onProgress) => {
+  const data = new FormData()
+  data.append('file', file)
+  data.append('file_name', file.name)
+  data.append('scope', scope)
+  return request({
+    url: 'cloudFile/upload',
+    method: 'post',
+    data,
+    headers: { 'Content-Type': 'multipart/form-data' },
+    timeout: 120000,
+    onUploadProgress: event => {
+      if (event.total && onProgress) onProgress(event.loaded / event.total)
+    }
+  })
+}
+export const renameCloudFile = data => request({ url: 'cloudFile/rename', method: 'post', data, timeout: 30000 })
+export const deleteCloudFile = data => request({ url: 'cloudFile/delete', method: 'post', data, timeout: 30000 })

+ 19 - 0
src/api/user.js

@@ -0,0 +1,19 @@
+import request from '@/utils/request'
+
+// 统一分页列表方法
+export const getListByPage = (data) => {
+    return request({
+        url: 'user/getListByPage',
+        method: 'get',
+        params: data
+    })
+}
+
+// 禁用/启用用户
+export const toggleUserStatus = (data) => {
+    return request({
+        url: 'user/toggleUserStatus',
+        method: 'post',
+        data
+    })
+}

+ 11 - 0
src/assets/storage/README.md

@@ -0,0 +1,11 @@
+# 云存储品牌图标
+
+本目录中的三个 SVG 图标来自 [Lobe Icons](https://github.com/lobehub/lobe-icons) 的
+`@lobehub/icons-static-svg` 包(MIT License),并在项目内本地加载,避免管理后台依赖第三方 CDN。
+
+- `alibabacloud-color.svg`:AlibabaCloud
+- `tencentcloud-color.svg`:TencentCloud
+- `qiniu-color.svg`:Qiniu
+
+品牌名称和图形仍归各自权利人所有。七牛云图标同时依据其
+[官方品牌标识页](https://developer.qiniu.com/brand)核对。

+ 1 - 0
src/assets/storage/alibabacloud-color.svg

@@ -0,0 +1 @@
+<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AlibabaCloud</title><path d="M14.752 4.64h5.274C22.242 4.64 24 6.475 24 8.691V15.8a3.947 3.947 0 01-3.974 3.975h-5.274l1.299-1.835 3.822-1.222c.688-.23 1.146-.918 1.146-1.605v-5.81c0-.687-.458-1.375-1.146-1.605L16.05 6.475l-1.3-1.835zM2.98 15.111c0 .688.46 1.376 1.147 1.606l3.822 1.146 1.3 1.835H3.974A3.947 3.947 0 010 15.723V8.69c0-2.216 1.758-4.05 3.975-4.05h5.273L7.95 6.474 4.127 7.697c-.688.23-1.146.918-1.146 1.606v5.808z" fill="#FF6A00"/><path d="M16.051 11.213H8.025v1.835h8.026v-1.835z" fill="#FF6A00"/></svg>

+ 1 - 0
src/assets/storage/qiniu-color.svg

@@ -0,0 +1 @@
+<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qiniu</title><path d="M23.111 4.6a.914.914 0 00-.861.161A13.443 13.443 0 017.947 8.897L7.38 6.831a1.076 1.076 0 00-1.211-.698l.27 2.18c-1.816-.827-2.313-.946-3.587-2.45C2.674 5.729 1.263 4.472.89 4.6a11.906 11.906 0 005.892 6.497l.738 5.97s.33 2.286 2.473 2.286h4.586c2.144 0 2.474-2.286 2.474-2.286l.518-4.28c-1.393-.11-2.268.857-2.546 1.814-.465 1.614-.465 1.716-.557 1.998-.188.575-.806.644-.806.644h-2.753s-.617-.07-.806-.644c-.12-.371-.727-2.54-1.335-4.74A11.877 11.877 0 0023.11 4.599V4.6z" fill="#06AEEF"/></svg>

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
src/assets/storage/tencentcloud-color.svg


+ 35 - 0
src/components/AgreementEditor.vue

@@ -0,0 +1,35 @@
+<template>
+  <div class="agreement-editor">
+    <Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default" />
+    <Editor
+      :model-value="modelValue"
+      :default-config="editorConfig"
+      mode="default"
+      @update:model-value="value => emit('update:modelValue', value)"
+      @on-created="onCreated"
+    />
+  </div>
+</template>
+
+<script setup>
+import '@wangeditor/editor/dist/css/style.css'
+import { onBeforeUnmount, shallowRef } from 'vue'
+import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+
+defineProps({ modelValue: { type: String, default: '' } })
+const emit = defineEmits(['update:modelValue'])
+const editorRef = shallowRef()
+// 协议仅开放排版和链接;服务端使用同范围白名单清洗,禁用媒体与任意 HTML。
+const toolbarConfig = {
+  toolbarKeys: ['headerSelect', 'bold', 'italic', 'underline', 'through', '|', 'bulletedList', 'numberedList', 'blockquote', 'insertLink', '|', 'undo', 'redo']
+}
+const editorConfig = { placeholder: '请输入协议正文', maxLength: 50000 }
+function onCreated(editor) { editorRef.value = editor }
+onBeforeUnmount(() => editorRef.value?.destroy())
+</script>
+
+<style scoped>
+.agreement-editor { border: 1px solid #d9d9d9; border-radius: 6px; overflow: hidden; }
+.agreement-editor :deep(.w-e-toolbar) { border-bottom: 1px solid #d9d9d9; }
+.agreement-editor :deep(.w-e-text-container) { height: 520px !important; }
+</style>

+ 110 - 0
src/components/DataList.vue

@@ -0,0 +1,110 @@
+<template>
+    <a-table :columns="columns" :data-source="dataList" rowKey="id" :pagination="pagination" :loading="dataLoading"
+        :scroll="{ x: tableWidth, scrollToFirstRowOnChange: true, y: scrollY }"
+        :row-selection="rowSelection" @change="tableChange">
+        <template #bodyCell="{ text, record, index, column }">
+            <!-- 自定义slot -->
+            <template v-if="column.slot">
+                <slot :name="column.dataIndex" :text="text" :record="record" :index="index" :column="column"></slot>
+            </template>
+
+            <!-- 默认状态列 -->
+            <div v-else-if="column.dataIndex == 'status'">
+                <span v-if="record.status == 1" style="color: #2ba471;">开启</span>
+                <span v-else style="color: #999;">禁用</span>
+            </div>
+
+            <!-- 操作列 -->
+            <div v-else-if="column.type == 'action'">
+                <template v-for="(item, index) in column.options">
+                    <a-popconfirm v-if="item.type == 'delete'" title="确认删除该条数据?" ok-text="确认" cancel-text="取消"
+                        @confirm="deleteConfirm(record)">
+                        <a-button type="link" size="small" danger>{{ item.title }}</a-button>
+                    </a-popconfirm>
+                    <a-button v-else type="link" size="small" @click="onAction(item, record)">{{ item.title
+                        }}</a-button>
+                </template>
+            </div>
+        </template>
+    </a-table>
+</template>
+<script setup>
+import { onMounted, onBeforeUnmount, ref, reactive, watch } from 'vue'
+import { message } from 'ant-design-vue'
+import { notifyError } from '@/utils/request.js'
+import { listParams } from '@/utils/listParams.js'
+
+const props = defineProps({
+  columns: { type: Array, required: true },
+  api: { type: Function, required: true },
+  deleteApi: Function,
+  extraParams: { type: Object, default: () => ({}) },
+  tableWidth: { type: Number, default: 1400 },
+  scrollY: { type: Number, default: 540 },
+  rowSelection: { type: Object, default: null }
+})
+const emits = defineEmits(['action'])
+const dataLoading = ref(false)
+const dataList = ref([])
+const searchParams = ref({})
+const sort = ref({})
+let sequence = 0
+const pagination = reactive({
+  total: 0, current: 1, pageSize: 10,
+  pageSizeOptions: ['10', '20', '50', '100', '500'],
+  showQuickJumper: true, showSizeChanger: true,
+  showTotal: total => `总计 ${total} 条数据`
+})
+
+async function getDataList() {
+  const version = ++sequence
+  dataLoading.value = true
+  try {
+    const res = await props.api(listParams(pagination, searchParams.value, sort.value, props.extraParams))
+    if (version !== sequence) return
+    if (Array.isArray(res.result)) {
+      dataList.value = res.result
+      pagination.total = res.result.length
+    } else {
+      dataList.value = res.result.list
+      pagination.total = res.result.total
+      pagination.current = res.result.page
+      pagination.pageSize = res.result.pageSize
+    }
+  } catch (error) {
+    if (version === sequence) notifyError(error, '获取列表失败')
+  } finally {
+    if (version === sequence) dataLoading.value = false
+  }
+}
+
+function tableChange(page, filters, sorter, extra) {
+  pagination.current = extra.action === 'sort' || page.pageSize !== pagination.pageSize ? 1 : page.current
+  pagination.pageSize = page.pageSize
+  sort.value = sorter
+  return getDataList()
+}
+function onAction(item, record) { emits('action', item, record) }
+async function deleteConfirm(row) {
+  try {
+    await props.deleteApi(row)
+    message.success('删除成功')
+    await refreshData()
+  } catch (error) { notifyError(error, '删除失败') }
+}
+function refreshData() {
+  pagination.current = 1
+  return getDataList()
+}
+function search(params) {
+  searchParams.value = { ...params }
+  return refreshData()
+}
+function reset() { return search({}) }
+
+onMounted(getDataList)
+onBeforeUnmount(() => { sequence++ })
+watch(() => props.extraParams, refreshData, { deep: true })
+defineExpose({ refreshData, search, reset })
+</script>
+<style lang="scss" scoped></style>

+ 142 - 0
src/components/SearchForm.vue

@@ -0,0 +1,142 @@
+<template>
+  <div>
+    <a-form ref="formRef" name="advanced_search" class="ant-advanced-search-form" :model="formData"
+      @finish="handleSearch">
+      <a-row :gutter="24">
+        <template v-for="(field, index) in fields" :key="field.prop">
+          <a-col v-show="isExpanded || index < maxFirstRowItems" :span="8">
+            <a-form-item :name="field.prop" :label="field.label">
+              <!-- 选择器 -->
+              <a-select v-if="field.type === 'select'" v-model:value="formData[field.prop]"
+                :placeholder="field.placeholder || '请选择'" allow-clear>
+                <a-select-option v-for="option in field.options" :key="option.value" :value="option.value">
+                  {{ option.label }}
+                </a-select-option>
+              </a-select>
+
+              <!-- 输入框 -->
+              <a-input v-else-if="field.type === 'input'" v-model:value="formData[field.prop]"
+                :placeholder="field.placeholder || '请输入'" allow-clear />
+
+              <!-- 日期范围选择器 -->
+              <a-range-picker v-else-if="field.type === 'daterange'" v-model:value="formData[field.prop]"
+                format="YYYY-MM-DD" value-format="YYYY-MM-DD" />
+            </a-form-item>
+          </a-col>
+        </template>
+      </a-row>
+      <a-row>
+        <a-col :span="24" style="text-align: right">
+          <a-button type="primary" html-type="submit" :loading="loading">
+            搜索
+          </a-button>
+          <a-button style="margin: 0 8px" @click="handleReset">
+            重置
+          </a-button>
+          <a v-if="hasExpandableFields" style="font-size: 12px" @click="toggleExpand">
+            <template v-if="isExpanded">
+              <UpOutlined />
+            </template>
+            <template v-else>
+              <DownOutlined />
+            </template>
+            {{ isExpanded ? '收起' : '展开' }}
+          </a>
+        </a-col>
+      </a-row>
+    </a-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+import { DownOutlined, UpOutlined } from '@ant-design/icons-vue'
+
+// Props
+const props = defineProps({
+  // 表单数据
+  searchForm: {
+    type: Object,
+    default: () => ({})
+  },
+  // 表单字段配置
+  searchFields: {
+    type: Array,
+    default: () => []
+  },
+  // 加载状态
+  loading: {
+    type: Boolean,
+    default: false
+  },
+  // 第一行最大显示项数
+  maxFirstRowItems: {
+    type: Number,
+    default: 6
+  }
+})
+
+// Emits
+const emit = defineEmits(['search', 'reset'])
+
+// 响应式数据
+const isExpanded = ref(false)
+const formRef = ref()
+const formData = ref({ ...props.searchForm })
+
+// 监听外部表单数据变化
+watch(
+  () => props.searchForm,
+  (newVal) => {
+    formData.value = { ...newVal }
+  },
+  { deep: true }
+)
+
+// 计算属性
+const fields = computed(() => {
+  return props.searchFields
+})
+
+const hasExpandableFields = computed(() => {
+  return props.searchFields.length > props.maxFirstRowItems
+})
+
+// 方法
+const handleSearch = () => {
+  emit('search', formData.value)
+}
+
+const handleReset = () => {
+  formRef.value?.resetFields()
+  // 重置表单数据
+  const resetData = {}
+  props.searchFields.forEach(field => {
+    resetData[field.prop] = field.type === 'daterange' ? [] : ''
+  })
+  formData.value = resetData
+  emit('reset', resetData)
+}
+
+const toggleExpand = () => {
+  isExpanded.value = !isExpanded.value
+}
+</script>
+
+<style scoped>
+.ant-advanced-search-form {
+  max-width: none;
+  background: #fff;
+  padding: 24px;
+  border-radius: 8px;
+  box-shadow: 2px 0 8px #f5f5f5;
+  margin-bottom: 16px;
+}
+
+[data-theme='dark'] .ant-advanced-search-form {
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid #434343;
+  padding: 24px;
+  border-radius: 2px;
+}
+</style>

+ 38 - 0
src/layout/components/Breadcrumb.vue

@@ -0,0 +1,38 @@
+<template>
+  <div class="content-breadcrumb">
+    <a-breadcrumb class="breadcrumb">
+      <a-breadcrumb-item><home-outlined /></a-breadcrumb-item>
+      <template v-for="(item, index) in route.matched" key="index">
+        <a-breadcrumb-item v-if="item.name != 'Layout'">{{ item.meta.title }}</a-breadcrumb-item>
+      </template>
+    </a-breadcrumb>
+  </div>
+</template>
+
+<script setup>
+import { useRoute } from 'vue-router'
+import { HomeOutlined } from '@ant-design/icons-vue'
+
+const route = useRoute()
+
+</script>
+
+<style lang="less" scoped>
+.content-breadcrumb {
+  margin-bottom: 20px;
+}
+
+.breadcrumb {
+  :deep(.ant-breadcrumb-link) {
+    color: #616161;
+
+    &:hover {
+      color: @primary-color;
+    }
+  }
+
+  :deep(.ant-breadcrumb-separator) {
+    color: #8c8c8c;
+  }
+}
+</style>

+ 25 - 0
src/layout/components/Footer.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="footer">
+    <p>© 2024 云喵软件开发工作室 版权所有</p>
+  </div>
+</template>
+
+<script setup>
+// Footer组件 - 全局页脚
+</script>
+
+<style lang="less" scoped>
+.footer {
+  padding: 24px;
+  text-align: center;
+  border-top: 1px solid #e8e8e8;
+  margin-top: 32px;
+  
+  p {
+    margin: 0;
+    font-size: 14px;
+    color: #8c8c8c;
+    line-height: 1.5;
+  }
+}
+</style>

+ 173 - 0
src/layout/components/Header.vue

@@ -0,0 +1,173 @@
+<template>
+  <a-layout-header class="custom-header">
+    <div class="header-left">
+      <div class="logo">
+        <img src="/mono-admin-logo.png" style="height: 64px;" />
+      </div>
+    </div>
+
+    <div class="header-right">
+      <a-dropdown placement="bottomRight">
+        <div class="user-info">
+          <a-avatar 
+            size="small" 
+            :src="userInfo.avatar" 
+            style="background-color: var(--meow-color-primary); margin-right: 8px;"
+          >
+            {{ userAvatar }}
+          </a-avatar>
+          <span class="user-name">{{ userName }}</span>
+          <down-outlined class="dropdown-icon" />
+        </div>
+        <template #overlay>
+          <a-menu class="user-menu">
+            <a-menu-item @click="handleProfile">
+              <user-outlined />
+              <span style="margin-left: 8px;">个人中心</span>
+            </a-menu-item>
+            <a-menu-item @click="handleSettings">
+              <setting-outlined />
+              <span style="margin-left: 8px;">账户设置</span>
+            </a-menu-item>
+            <a-menu-divider />
+            <a-menu-item @click="handleLogout">
+              <logout-outlined />
+              <span style="margin-left: 8px;">退出登录</span>
+            </a-menu-item>
+          </a-menu>
+        </template>
+      </a-dropdown>
+    </div>
+  </a-layout-header>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useUserStore } from "@/stores/user.js"
+
+const router = useRouter()
+const userStore = useUserStore()
+
+// 计算用户信息
+const userInfo = computed(() => userStore.userInfo || {})
+const userName = computed(() => userInfo.value.username || 'Guest')
+const userAvatar = computed(() => {
+  if (userInfo.value.avatar) {
+    return userInfo.value.avatar
+  }
+  // 使用用户名首字母作为头像
+  return userName.value.charAt(0).toUpperCase()
+})
+
+function handleLogout() {
+  userStore.outLogin()
+}
+
+function handleProfile() {
+  router.push('/profile')
+}
+
+function handleSettings() {
+  router.push('/settings')
+}
+</script>
+
+<style lang="less" scoped>
+.custom-header {
+  background: #fff;
+  padding: 0 24px;
+  box-shadow: 2px 0 8px #f5f5f5;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 24px;
+  height: 64px;
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  width: 100%;
+  z-index: 1001;
+  transition: all 0.3s;
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.logo {
+  display: flex;
+  align-items: center;
+  padding: 0;
+  margin-right: auto;
+}
+
+.header-right {
+  display: flex;
+  align-items: center;
+  height: 100%;
+  flex-shrink: 0;
+  gap: 16px;
+}
+
+.user-info {
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  padding: 4px 8px;
+  border-radius: 8px;
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+  position: relative;
+  overflow: hidden;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 100%;
+    height: 100%;
+    background: linear-gradient(90deg, transparent, rgba(0, 82, 217, 0.1), transparent);
+    transition: left 0.5s ease;
+  }
+
+  &:hover {
+    background: var(--meow-color-primary-light);
+    transform: translateY(-1px);
+    box-shadow: 0 2px 8px rgba(0, 82, 217, 0.15);
+
+    &::before {
+      left: 100%;
+    }
+
+    .user-name {
+      color: @primary-color;
+    }
+
+    .dropdown-icon {
+      color: @primary-color;
+      transform: rotate(180deg);
+    }
+  }
+}
+
+.user-name {
+  font-size: 14px;
+  color: #1a1a1a;
+  margin-right: 4px;
+}
+
+.dropdown-icon {
+  font-size: 12px;
+  color: #595959;
+}
+
+.user-menu {
+  min-width: 160px;
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+</style>

+ 367 - 0
src/layout/components/Sidebar.vue

@@ -0,0 +1,367 @@
+<template>
+  <a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="280" :collapsed-width="64"
+    class="sider-wrap">
+    <a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" :inline-collapsed="collapsed"
+      class="menu-wrap">
+      <template v-for="item in menuStore.menuList" :key="item.key">
+        <!-- 单级菜单 -->
+        <template v-if="!item.children">
+          <a-menu-item :key="item.path" class="menu-item" @click="handleMenuClick(item)">
+            <template #icon>
+              <component :is="item.icon" class="menu-icon" v-if="item.icon" />
+            </template>
+            <span class="menu-title">{{ item.title }}</span>
+          </a-menu-item>
+        </template>
+
+        <!-- 多级菜单 -->
+        <template v-else>
+          <a-sub-menu :key="item.id" class="sub-menu">
+            <template #icon>
+              <component :is="item.icon" class="menu-icon" v-if="item.icon" />
+            </template>
+            <template #title>
+              <span class="menu-title">{{ item.title }}</span>
+            </template>
+
+            <!-- 展开状态的自定义子菜单 -->
+            <div class="sub-menu-content" v-if="!collapsed">
+              <div class="sub-menu-grid" :class="{ 'single-item': item.children.length === 1 }">
+                <div v-for="child in item.children" :key="child.path" class="sub-menu-item"
+                  :class="{ 'active': selectedKeys.includes(child.path) }" @click="handleMenuClick(child)">
+                  <span>{{ child.title }}</span>
+                </div>
+              </div>
+            </div>
+
+            <!-- 折叠状态的标准子菜单 -->
+            <template v-else>
+              <a-menu-item v-for="child in item.children.slice(0, 2)" :key="child.path" @click="handleMenuClick(child)">
+                <span>{{ child.title }}</span>
+              </a-menu-item>
+            </template>
+          </a-sub-menu>
+        </template>
+      </template>
+    </a-menu>
+
+    <!-- 侧边栏伸缩按钮 -->
+    <a-button type="text" class="sidebar-toggle-btn" @click="toggleCollapsed">
+      <right-outlined v-if="collapsed" />
+      <left-outlined v-else />
+    </a-button>
+  </a-layout-sider>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useMenuStore } from "@/stores/menu.js"
+
+const route = useRoute()
+const router = useRouter()
+const menuStore = useMenuStore()
+
+const collapsed = ref(false)
+const selectedKeys = ref([route.path])
+const openKeys = ref([])
+
+onMounted(() => {
+  defaultOpenKeys()
+})
+
+function defaultOpenKeys() {
+  if (menuStore.menuList) {
+    menuStore.menuList.forEach(element => {
+      openKeys.value.push(element.id)
+    });
+  }
+}
+
+
+function handleMenuClick(item) {
+  selectedKeys.value = [item.path]
+  router.push(item.path)
+}
+
+// 查找菜单项
+const findMenuItem = (items, key) => {
+  for (const item of items) {
+    if (item.key === key) {
+      return item
+    }
+    if (item.children) {
+      const found = findMenuItem(item.children, key)
+      if (found) return found
+    }
+  }
+  return null
+}
+
+// 切换折叠状态
+const toggleCollapsed = () => {
+  collapsed.value = !collapsed.value
+}
+
+
+// 暴露方法给父组件
+defineExpose({
+  collapsed,
+  toggleCollapsed
+})
+</script>
+
+<style lang="less" scoped>
+/* 侧边栏样式 */
+.sider-wrap {
+  background: #fff;
+  box-shadow: 2px 0 8px #f5f5f5;
+  overflow-x: hidden;
+  overflow-y: auto;
+  flex-shrink: 0;
+  height: 100%;
+  /* 隐藏滚动条 */
+  scrollbar-width: none;
+  /* Firefox */
+  -ms-overflow-style: none;
+  /* IE and Edge */
+
+  &::-webkit-scrollbar {
+    display: none;
+    /* Chrome, Safari and Opera */
+  }
+}
+
+/* 菜单样式 */
+.menu-wrap {
+  border: none;
+  background: transparent;
+  height: 100%;
+
+  :deep(.ant-menu-item),
+  :deep(.ant-menu-submenu-title) {
+    margin: 4px 8px;
+    border-radius: 8px;
+    background: transparent;
+    color: #333;
+    height: 44px;
+    line-height: 44px;
+    position: relative;
+    border-bottom: 3px solid transparent;
+    transition: all 0.3s ease;
+
+    &:hover {
+      color: @primary-color;
+      
+      .menu-title {
+        position: relative;
+        
+
+      }
+    }
+
+    &.ant-menu-item-selected {
+      color: @primary-color;
+      background: transparent;
+      
+      .menu-title {
+        position: relative;
+        
+
+      }
+    }
+  }
+
+  // 折叠状态下的菜单项样式
+  &.ant-menu-inline-collapsed {
+    width: 64px;
+
+    :deep(.ant-menu-item),
+    :deep(.ant-menu-submenu-title) {
+      width: 64px !important;
+      height: 48px !important;
+      line-height: 48px !important;
+      padding: 0 !important;
+      margin: 4px 0 !important;
+      text-align: center !important;
+      display: flex !important;
+      align-items: center !important;
+      justify-content: center !important;
+      position: relative;
+
+      /* 隐藏文本内容 */
+      .ant-menu-title-content {
+        display: none !important;
+      }
+
+      /* 图标居中 */
+      .ant-menu-item-icon {
+        width: 100% !important;
+        text-align: center !important;
+        margin: 0 !important;
+        display: flex !important;
+        align-items: center !important;
+        justify-content: center !important;
+
+        .anticon {
+          font-size: 16px !important;
+          margin: 0 !important;
+        }
+      }
+      
+      &:hover {
+      }
+      
+      &.ant-menu-item-selected {
+      }
+    }
+
+    /* 隐藏子菜单箭头 */
+    :deep(.ant-menu-submenu-arrow) {
+      display: none !important;
+    }
+  }
+
+  :deep(.ant-menu-submenu-title) {
+    background: transparent !important;
+
+    &:hover {
+      background: #f5f5f5;
+      color: @primary-color;
+    }
+  }
+}
+
+.menu-icon {
+  font-size: 16px;
+}
+
+.menu-title {
+  font-size: 14px;
+  font-weight: 500;
+}
+
+/* 子菜单内容区域 */
+.sub-menu-content {
+  padding: 8px 16px;
+  background: transparent;
+  border-radius: 8px;
+  margin: 8px 0;
+  border: none;
+}
+
+/* 子菜单网格布局 */
+.sub-menu-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 4px;
+  padding: 0;
+
+  /* 统一所有子菜单项的样式,无论数量多少 */
+  .sub-menu-item {
+    width: 100%;
+    max-width: none;
+    margin: 0 !important;
+    border-radius: 6px !important;
+    height: 36px !important;
+    line-height: 36px !important;
+    font-size: 14px !important;
+    padding: 0 4px !important;
+    background: transparent !important;
+    border: none !important;
+    transition: all 0.2s;
+    text-align: center;
+    cursor: pointer;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: flex !important;
+    align-items: center;
+    justify-content: center;
+    min-width: 0;
+
+    &:hover {
+      color: @primary-color !important;
+      background: transparent !important;
+      position: relative;
+      
+
+    }
+
+    &.ant-menu-item-selected {
+      background: transparent !important;
+      color: @primary-color !important;
+      font-weight: 500;
+      
+      span {
+        position: relative;
+        
+
+      }
+    }
+
+    span {
+      font-size: 14px;
+      color: inherit;
+      font-weight: inherit;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      max-width: 100%;
+    }
+
+    &.active {
+      background: transparent !important;
+      color: @primary-color !important;
+      position: relative;
+      
+
+    }
+  }
+
+  &.single-item {
+    grid-template-columns: repeat(2, 1fr);
+
+    .sub-menu-item {
+      grid-column: 1;
+    }
+  }
+}
+
+/* 侧边栏切换按钮 */
+.sidebar-toggle-btn {
+  position: fixed;
+  left: 280px;
+  top: 50%;
+  transform: translateY(-50%);
+  z-index: 2;
+  width: 0px;
+  height: 70px;
+  border-radius: 0 12px 12px 0;
+  background: @primary-color;
+  color: #fff;
+  border: none;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 2px 6px rgba(0, 0, 0, 0.1);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s;
+  padding: 13px;
+ 
+  &:hover {
+    background: @primary-color;
+    color: #fff;
+    transform: translateY(-50%) scale(1.1);
+    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2), 0 3px 8px rgba(0, 0, 0, 0.15);
+  }
+}
+
+/* 折叠状态下的按钮位置 */
+.sider-wrap.ant-layout-sider-collapsed .sidebar-toggle-btn {
+  left: 64px;
+}
+
+:deep(.ant-menu-inline) {
+  border-inline-end: none !important;
+}
+</style>

+ 59 - 0
src/layout/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="layout-container">
+    <!-- 顶部导航 -->
+    <Header />
+
+    <!-- 主体区域 -->
+    <div class="layout-body">
+      <!-- 侧边栏 -->
+      <Sidebar />
+
+      <!-- 内容区域 -->
+      <div class="layout-content">
+        <!-- 面包屑导航 -->
+        <Breadcrumb />
+        <div class="content-body">
+          <router-view v-slot="{ Component }">
+            <component :is="Component" />
+          </router-view>
+        </div>
+        <!-- 页脚 -->
+        <Footer />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import Header from './components/Header.vue'
+import Sidebar from './components/Sidebar.vue'
+import Breadcrumb from './components/Breadcrumb.vue'
+import Footer from './components/Footer.vue'
+</script>
+<style lang="less" scoped>
+/* 布局容器 */
+.layout-container {
+  width: 100%;
+  height: 100vh;
+  overflow: hidden;
+}
+
+/* 主体区域 */
+.layout-body {
+  display: flex;
+  height: calc(100vh - 64px);
+  margin-top: 64px;
+  padding-top: 20px;
+}
+
+.layout-content {
+  flex: 1;
+  overflow-y: auto;
+  transition: all 0.3s;
+  padding: 0 20px 20px 20px;
+}
+
+.content-body {
+  min-height: 70vh;
+}
+</style>

+ 32 - 0
src/main.js

@@ -0,0 +1,32 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import router from './router'
+import App from './App.vue'
+import Antd from 'ant-design-vue'
+import 'ant-design-vue/dist/reset.css'
+import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
+
+import DataList from "@/components/DataList.vue";
+
+const app = createApp(App)
+
+app.component("DataList", DataList);
+
+//全局注册图标组件
+import * as antIcons from "@ant-design/icons-vue";
+
+Object.keys(antIcons).forEach((key) => {
+  // @ts-ignore
+  app.component(key, antIcons[key]);
+});
+
+app.config.globalProperties.$antIcons = antIcons;
+
+const pinia = createPinia()
+pinia.use(piniaPluginPersistedstate)
+
+app.use(pinia)
+app.use(router)
+app.use(Antd)
+
+app.mount('#app')

+ 71 - 0
src/router/index.js

@@ -0,0 +1,71 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useMenuStore } from "@/stores/menu.js"
+import { useUserStore } from "@/stores/user.js"
+
+const routes = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/Login.vue'),
+    meta: { title: '登录' }
+  },
+  {
+    path: '/',
+    name: 'Layout',
+    component: () => import('@/layout/index.vue'),
+    redirect: '/dashboard',
+    children: [
+      {
+        path: 'profile',
+        name: 'Profile',
+        component: () => import('@/views/Profile.vue'),
+        meta: { title: '个人中心' }
+      },
+      {
+        path: 'settings',
+        name: 'Settings',
+        component: () => import('@/views/Settings.vue'),
+        meta: { title: '账户设置' }
+      }
+    ]
+  },
+]
+
+const router = createRouter({
+  history: createWebHistory('/admin/'),
+  routes
+})
+
+const removers = []
+export function clearDynamicRoutes() {
+  for (const remove of removers.splice(0)) remove()
+}
+
+router.beforeEach(async to => {
+  const user = useUserStore()
+  const menus = useMenuStore()
+  if (to.meta.title) document.title = `${to.meta.title}-MonoAdmin`
+  if (to.path === '/login') return user.token ? '/' : true
+  if (!user.token) return '/login'
+  if (!menus.loaded) {
+    const token = user.token
+    try {
+      const accessRoutes = await menus.getMenus()
+      if (token !== user.token) return false
+      clearDynamicRoutes()
+      for (const route of accessRoutes) {
+        if (!router.hasRoute(route.name)) removers.push(router.addRoute('Layout', route))
+      }
+      // 空菜单也是合法结果,个人中心仍可用。
+      if (!accessRoutes.length) return to.path === '/profile' ? true : '/profile'
+      return { path: to.fullPath, replace: true }
+    } catch (error) {
+      if (token !== user.token) return false
+      user.clearSession()
+      return '/login'
+    }
+  }
+  if (!to.matched.length) return '/profile'
+  return true
+})
+export default router

+ 42 - 0
src/stores/menu.js

@@ -0,0 +1,42 @@
+import { defineStore } from 'pinia'
+import { getUserMenus } from '@/api/auth.js'
+import { useUserStore } from './user.js'
+
+const modules = import.meta.glob('../views/**/*.vue')
+let pending = null
+let generation = 0
+
+function mapRoutes(routes) {
+  return routes.map(route => ({
+    ...route,
+    component: modules[`../views/${route.component}.vue`],
+    ...(route.children ? { children: mapRoutes(route.children) } : {})
+  }))
+}
+
+export const useMenuStore = defineStore('menu', {
+  state: () => ({ menuList: [], loaded: false }),
+  actions: {
+    reset() {
+      generation++
+      pending = null
+      this.menuList = []
+      this.loaded = false
+    },
+    async getMenus() {
+      if (pending) return pending
+      const version = generation
+      const token = useUserStore().token
+      const task = getUserMenus().then(res => {
+        if (version !== generation || token !== useUserStore().token) {
+          throw Object.assign(new Error('会话已改变'), { notified: true })
+        }
+        this.menuList = res.result
+        this.loaded = true
+        return mapRoutes(res.result)
+      })
+      pending = task
+      try { return await task } finally { if (pending === task) pending = null }
+    }
+  }
+})

+ 47 - 0
src/stores/user.js

@@ -0,0 +1,47 @@
+import { defineStore } from 'pinia'
+import { login, getInfo, outLogin } from '@/api/auth.js'
+import { useMenuStore } from './menu.js'
+import router, { clearDynamicRoutes } from '../router'
+import { notifyError } from '@/utils/request.js'
+
+let sessionVersion = 0
+
+export const useUserStore = defineStore('user', {
+  persist: true,
+  state: () => ({ token: null, userInfo: null }),
+  actions: {
+    clearSession() {
+      sessionVersion++
+      this.token = null
+      this.userInfo = null
+      useMenuStore().reset()
+      clearDynamicRoutes()
+    },
+    async accountLogin(formData, loading) {
+      if (loading.value) return
+      loading.value = true
+      this.clearSession()
+      const version = sessionVersion
+      try {
+        const res = await login(formData)
+        if (version !== sessionVersion) return
+        this.token = res.result.token
+        this.userInfo = (await getInfo()).result
+        await router.replace('/')
+      } catch (error) {
+        if (version !== sessionVersion) return
+        this.clearSession()
+        notifyError(error, '登录失败')
+      } finally {
+        loading.value = false
+      }
+    },
+    async outLogin() {
+      const pending = this.token ? outLogin(this.token) : Promise.resolve()
+      // 远端不可用也立即清理本地身份、菜单和路由。
+      this.clearSession()
+      await router.replace('/login')
+      try { await pending } catch (error) { notifyError(error, '已退出本地登录') }
+    }
+  }
+})

+ 4 - 0
src/styles/theme.module.less

@@ -0,0 +1,4 @@
+// 从现有 Less 主色导出运行时 token,避免组件库与业务样式各用一套主色。
+:export {
+  primaryColor: @primary-color;
+}

+ 44 - 0
src/styles/variables.less

@@ -0,0 +1,44 @@
+// 主题颜色
+@primary-color: #0052d9;
+@primary-active-color: #003cab;
+@primary-light-color: #f2f3ff;
+
+.card-wrap {
+    background-color: #fff;
+    padding: 20px;
+    border-radius: 10px;
+    box-shadow: 2px 0 8px #f5f5f5;
+    overflow: hidden;
+}
+
+.mb10 {
+    margin-bottom: 10px;
+}
+
+.mb20 {
+    margin-bottom: 20px;
+}
+
+.mt10 {
+    margin-top: 10px;
+}
+
+.mt20 {
+    margin-top: 20px;
+}
+
+.ml10 {
+    margin-left: 10px;
+}
+
+.ml20 {
+    margin-left: 20px;
+}
+
+.mr10 {
+    margin-right: 10px;
+}
+
+.mr20 {
+    margin-right: 20px;
+}

+ 165 - 0
src/utils/date.js

@@ -0,0 +1,165 @@
+/**
+ * 日期工具函数
+ */
+
+/**
+ * 格式化日期
+ * @param {string|Date} date - 日期字符串或Date对象
+ * @param {string} format - 格式化模板,默认为 'YYYY-MM-DD HH:mm:ss'
+ * @returns {string} 格式化后的日期字符串
+ */
+export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
+  if (!date) return '-'
+  
+  const d = new Date(date)
+  if (isNaN(d.getTime())) return '-'
+  
+  const year = d.getFullYear()
+  const month = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  const hours = String(d.getHours()).padStart(2, '0')
+  const minutes = String(d.getMinutes()).padStart(2, '0')
+  const seconds = String(d.getSeconds()).padStart(2, '0')
+  
+  return format
+    .replace('YYYY', year)
+    .replace('MM', month)
+    .replace('DD', day)
+    .replace('HH', hours)
+    .replace('mm', minutes)
+    .replace('ss', seconds)
+}
+
+/**
+ * 格式化相对时间
+ * @param {string|Date} date - 日期字符串或Date对象
+ * @returns {string} 相对时间字符串
+ */
+export function formatRelativeTime(date) {
+  if (!date) return '-'
+  
+  const d = new Date(date)
+  if (isNaN(d.getTime())) return '-'
+  
+  const now = new Date()
+  const diff = now.getTime() - d.getTime()
+  const seconds = Math.floor(diff / 1000)
+  const minutes = Math.floor(seconds / 60)
+  const hours = Math.floor(minutes / 60)
+  const days = Math.floor(hours / 24)
+  
+  if (seconds < 60) {
+    return '刚刚'
+  } else if (minutes < 60) {
+    return `${minutes}分钟前`
+  } else if (hours < 24) {
+    return `${hours}小时前`
+  } else if (days < 7) {
+    return `${days}天前`
+  } else {
+    return formatDate(date, 'YYYY-MM-DD')
+  }
+}
+
+/**
+ * 获取日期范围
+ * @param {string} type - 类型:today, week, month, year
+ * @returns {Array} [开始日期, 结束日期]
+ */
+export function getDateRange(type) {
+  const now = new Date()
+  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+  
+  switch (type) {
+    case 'today':
+      return [
+        formatDate(today, 'YYYY-MM-DD'),
+        formatDate(now, 'YYYY-MM-DD')
+      ]
+    
+    case 'week': {
+      const weekStart = new Date(today)
+      weekStart.setDate(today.getDate() - today.getDay())
+      return [
+        formatDate(weekStart, 'YYYY-MM-DD'),
+        formatDate(now, 'YYYY-MM-DD')
+      ]
+    }
+    
+    case 'month': {
+      const monthStart = new Date(today.getFullYear(), today.getMonth(), 1)
+      return [
+        formatDate(monthStart, 'YYYY-MM-DD'),
+        formatDate(now, 'YYYY-MM-DD')
+      ]
+    }
+    
+    case 'year': {
+      const yearStart = new Date(today.getFullYear(), 0, 1)
+      return [
+        formatDate(yearStart, 'YYYY-MM-DD'),
+        formatDate(now, 'YYYY-MM-DD')
+      ]
+    }
+    
+    default:
+      return [null, null]
+  }
+}
+
+/**
+ * 判断是否为今天
+ * @param {string|Date} date - 日期
+ * @returns {boolean}
+ */
+export function isToday(date) {
+  if (!date) return false
+  
+  const d = new Date(date)
+  const today = new Date()
+  
+  return d.getFullYear() === today.getFullYear() &&
+         d.getMonth() === today.getMonth() &&
+         d.getDate() === today.getDate()
+}
+
+/**
+ * 判断是否为本周
+ * @param {string|Date} date - 日期
+ * @returns {boolean}
+ */
+export function isThisWeek(date) {
+  if (!date) return false
+  
+  const d = new Date(date)
+  const now = new Date()
+  const weekStart = new Date(now)
+  weekStart.setDate(now.getDate() - now.getDay())
+  weekStart.setHours(0, 0, 0, 0)
+  
+  return d >= weekStart
+}
+
+/**
+ * 判断是否为本月
+ * @param {string|Date} date - 日期
+ * @returns {boolean}
+ */
+export function isThisMonth(date) {
+  if (!date) return false
+  
+  const d = new Date(date)
+  const now = new Date()
+  
+  return d.getFullYear() === now.getFullYear() &&
+         d.getMonth() === now.getMonth()
+}
+
+export default {
+  formatDate,
+  formatRelativeTime,
+  getDateRange,
+  isToday,
+  isThisWeek,
+  isThisMonth
+}

+ 15 - 0
src/utils/listParams.js

@@ -0,0 +1,15 @@
+// 后台列表统一在此将界面筛选转换为接口参数,页面不用自行拆日期。
+export function listParams(pagination, filters = {}, sorter = {}, extra = {}) {
+  const { date_range, ...queryForm } = filters
+  if (date_range?.length === 2) {
+    queryForm.created_at = date_range.map(value => typeof value?.format === 'function' ? value.format('YYYY-MM-DD') : value)
+  }
+  return {
+    ...extra,
+    page: pagination.current,
+    pageSize: pagination.pageSize,
+    queryForm: { ...(extra.queryForm || {}), ...queryForm },
+    orderField: sorter.order ? sorter.field : 'created_at',
+    orderDirection: sorter.order === 'ascend' ? 'asc' : 'desc'
+  }
+}

+ 55 - 0
src/utils/request.js

@@ -0,0 +1,55 @@
+import axios from 'axios'
+import { message } from 'ant-design-vue'
+import { useUserStore } from '@/stores/user.js'
+import router from '@/router'
+
+export function notifyError(error, fallback = '请求失败') {
+  if (error?.notified) return
+  message.error(error?.message || fallback)
+  if (error && typeof error === 'object') error.notified = true
+}
+
+const request = axios.create({
+  baseURL: import.meta.env.VITE_API_BASE_URL,
+  timeout: 10000,
+  headers: { 'Content-Type': 'application/json' }
+})
+
+request.interceptors.request.use(config => {
+  const token = Object.hasOwn(config, 'sessionToken') ? config.sessionToken : useUserStore().token
+  config.sessionToken = token
+  if (token) config.headers.authorization = token
+  return config
+})
+
+function rejectFailure(error, config) {
+  const user = useUserStore()
+  // 上个账号的迟到响应不能清空新账号,或在退出后继续驱动页面。
+  if (config && config.sessionToken !== user.token) {
+    return Promise.reject(Object.assign(new Error('会话已改变'), { notified: true }))
+  }
+  if (error.code === 2001) {
+    user.clearSession()
+    router.replace('/login')
+  }
+  notifyError(error)
+  return Promise.reject(error)
+}
+
+request.interceptors.response.use(response => {
+  if (response.config.sessionToken !== useUserStore().token) {
+    return rejectFailure(new Error('会话已改变'), response.config)
+  }
+  if (response.data?.code === 200) return response.data
+  const error = Object.assign(new Error(response.data?.message || '请求失败'), {
+    code: response.data?.code, response
+  })
+  return rejectFailure(error, response.config)
+}, error => {
+  error.message = error.response
+    ? `请求失败(HTTP ${error.response.status})`
+    : '网络连接失败,请稍后重试'
+  return rejectFailure(error, error.config)
+})
+
+export default request

+ 12 - 0
src/utils/utils.js

@@ -0,0 +1,12 @@
+/**
+ * 获取完整的图片URL
+ * @param {string|object} image 图片文件ID、文件展示对象或完整地址
+ * @returns {string} 完整的图片URL
+ */
+export function getImageUrl(image) {
+  if (!image) return ''
+  if (typeof image === 'object') return image.thumbnail_url || image.url || ''
+
+  if (/^(https?:)?\/\//i.test(image) || image.startsWith('/')) return image
+  return `/file/${encodeURIComponent(image)}`
+}

+ 82 - 0
src/views/404.vue

@@ -0,0 +1,82 @@
+<template>
+  <div class="error-page">
+    <div class="error-content animate-on-enter">
+      <div class="error-number animate-on-enter animate-delay-1">404</div>
+      <div class="error-title animate-on-enter animate-delay-2">页面未找到</div>
+      <div class="error-description animate-on-enter animate-delay-3">
+        抱歉,您访问的页面不存在或已被移除
+      </div>
+      <div class="error-actions animate-on-enter animate-delay-4">
+        <a-button type="primary" size="large" @click="goHome">
+          返回首页
+        </a-button>
+        <a-button size="large" @click="goBack" style="margin-left: 16px;">
+          返回上页
+        </a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+
+const goHome = () => {
+  router.push('/')
+}
+
+const goBack = () => {
+  router.go(-1)
+}
+</script>
+
+<style lang="less" scoped>
+.error-page {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 100vh;
+  background: linear-gradient(135deg, var(--meow-color-primary) 0%, var(--meow-color-primary-active) 100%);
+  padding: 20px;
+}
+
+.error-content {
+  text-align: center;
+  color: white;
+}
+
+.error-number {
+  font-size: 120px;
+  font-weight: bold;
+  line-height: 1;
+  margin-bottom: 20px;
+  text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+  background: linear-gradient(45deg, #fff, #f0f0f0);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+}
+
+.error-title {
+  font-size: 32px;
+  font-weight: 600;
+  margin-bottom: 16px;
+  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+.error-description {
+  font-size: 16px;
+  margin-bottom: 32px;
+  opacity: 0.9;
+  line-height: 1.6;
+}
+
+.error-actions {
+  display: flex;
+  justify-content: center;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+</style>

+ 723 - 0
src/views/Dashboard.vue

@@ -0,0 +1,723 @@
+<template>
+  <div class="dashboard-page">
+    <!-- 统计卡片区域 -->
+    <div class="mb10">
+      <!-- 用户统计 -->
+      <div class="stats-wrapper">
+        <h3 class="stats-title">用户统计</h3>
+        <div class="stats-grid">
+            <div class="content-stat-card user-card animate-on-enter">
+              <div class="content-stat-value">{{ userStatistics.today || 0 }}</div>
+              <div class="content-stat-label">今日新增</div>
+            </div>
+
+            <div class="content-stat-card user-card animate-on-enter animate-delay-1">
+              <div class="content-stat-value">{{ userStatistics.week || 0 }}</div>
+              <div class="content-stat-label">本周新增</div>
+            </div>
+
+            <div class="content-stat-card user-card animate-on-enter animate-delay-2">
+              <div class="content-stat-value">{{ userStatistics.month || 0 }}</div>
+              <div class="content-stat-label">本月新增</div>
+            </div>
+
+            <div class="content-stat-card user-card animate-on-enter animate-delay-3">
+              <div class="content-stat-value">{{ userStatistics.lastMonth || 0 }}</div>
+              <div class="content-stat-label">上月新增</div>
+            </div>
+        </div>
+      </div>
+
+      <!-- 帖子统计 -->
+      <div class="stats-wrapper">
+        <h3 class="stats-title">帖子统计</h3>
+        <div class="stats-grid">
+            <div class="content-stat-card post-card animate-on-enter animate-delay-4">
+              <div class="content-stat-value">{{ postStatistics.today || 0 }}</div>
+              <div class="content-stat-label">今日新增</div>
+            </div>
+            
+            <div class="content-stat-card post-card animate-on-enter animate-delay-5">
+              <div class="content-stat-value">{{ postStatistics.week || 0 }}</div>
+              <div class="content-stat-label">本周新增</div>
+            </div>
+
+            <div class="content-stat-card post-card animate-on-enter animate-delay-6">
+              <div class="content-stat-value">{{ postStatistics.month || 0 }}</div>
+              <div class="content-stat-label">本月新增</div>
+            </div>
+
+            <div class="content-stat-card post-card animate-on-enter animate-delay-7">
+              <div class="content-stat-value">{{ postStatistics.lastMonth || 0 }}</div>
+              <div class="content-stat-label">上月新增</div>
+            </div>
+          </div>
+      </div>
+
+      <!-- 圈子统计 -->
+      <div class="stats-wrapper">
+        <h3 class="stats-title">圈子统计</h3>
+        <div class="stats-grid">
+            <div class="content-stat-card topic-card animate-on-enter animate-delay-8">
+              <div class="content-stat-value">{{ topicStatistics.today || 0 }}</div>
+              <div class="content-stat-label">今日新增</div>
+            </div>
+            
+            <div class="content-stat-card topic-card animate-on-enter animate-delay-9">
+              <div class="content-stat-value">{{ topicStatistics.week || 0 }}</div>
+              <div class="content-stat-label">本周新增</div>
+            </div>
+
+            <div class="content-stat-card topic-card animate-on-enter animate-delay-10">
+              <div class="content-stat-value">{{ topicStatistics.month || 0 }}</div>
+              <div class="content-stat-label">本月新增</div>
+            </div>
+
+            <div class="content-stat-card topic-card animate-on-enter animate-delay-11">
+              <div class="content-stat-value">{{ topicStatistics.lastMonth || 0 }}</div>
+              <div class="content-stat-label">上月新增</div>
+            </div>
+        </div>
+      </div>
+    </div>
+
+  <!-- 图表和信息区域 -->
+  <div class="mb10">
+    <a-row :gutter="10">
+      <!-- 用户增长趋势 -->
+      <a-col :xs="24" :lg="16">
+        <a-card title="用户增长趋势" class="chart-card animate-on-enter animate-delay-4">
+          <div class="chart-container" ref="userGrowthChart"></div>
+        </a-card>
+      </a-col>
+
+      <!-- 用户分布 -->
+      <a-col :xs="24" :lg="8">
+        <a-card title="用户分布" class="chart-card animate-on-enter animate-delay-5">
+          <div class="chart-container" ref="userDistributionChart"></div>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+
+  <!-- 最近日志和系统信息 -->
+  <div class="mb10">
+    <a-row :gutter="10">
+      <!-- 最近操作日志 -->
+      <a-col :xs="24" :lg="16">
+        <a-card title="最近操作日志" class="logs-card animate-on-enter animate-delay-4" :bordered="false">
+          <a-table :columns="logColumns" :data-source="recentLogs" :pagination="false" size="small"
+            :scroll="{ x: 800 }">
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'is_success'">
+                <a-tag :color="record.is_success ? 'success' : 'error'">
+                  {{ record.is_success ? '成功' : '失败' }}
+                </a-tag>
+              </template>
+              <template v-if="column.key === 'method'">
+                <a-tag :color="getMethodColor(record.method)">{{ record.method }}</a-tag>
+              </template>
+              <template v-if="column.key === 'duration'">
+                <span>{{ record.duration }}ms</span>
+              </template>
+            </template>
+          </a-table>
+        </a-card>
+      </a-col>
+
+      <!-- 系统信息 -->
+      <a-col :xs="24" :lg="8">
+        <a-card title="系统信息" class="animate-on-enter animate-delay-5" :bordered="false">
+          <div class="system-info">
+            <div class="info-item">
+              <span class="info-label">PHP版本:</span>
+              <span class="info-value">{{ systemInfo.php_version }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">服务器:</span>
+              <span class="info-value">{{ systemInfo.server_software }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">内存使用:</span>
+              <span class="info-value">{{ systemInfo.memory_usage }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">内存峰值:</span>
+              <span class="info-value">{{ systemInfo.memory_peak }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">磁盘空间:</span>
+              <span class="info-value">{{ systemInfo.disk_free }} / {{ systemInfo.disk_total }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">服务器时间:</span>
+              <span class="info-value">{{ systemInfo.server_time }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">时区:</span>
+              <span class="info-value">{{ systemInfo.timezone }}</span>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+  </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import themeColors from '@/styles/theme.module.less'
+import { onMounted, ref, nextTick } from 'vue'
+import {
+  UserOutlined,
+  TeamOutlined,
+  FileTextOutlined,
+  CrownOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+  CalendarOutlined,
+  ApartmentOutlined,
+  StarOutlined
+} from '@ant-design/icons-vue'
+import * as echarts from 'echarts'
+import {
+  getStatistics,
+  getRecentLogs,
+  getUserGrowth,
+  getSystemInfo,
+  getUserDistribution,
+  getUserStatistics,
+  getPostStatistics,
+  getTopicStatistics
+} from '@/api/dashboard'
+
+// 页面数据
+const statistics = ref({})
+const recentLogs = ref([])
+const userGrowthData = ref([])
+const userDistributionData = ref([])
+const systemInfo = ref({})
+const loading = ref(false)
+
+// 统计数据
+const userStatistics = ref({})
+const postStatistics = ref({})
+const topicStatistics = ref({})
+
+// 图表引用
+const userGrowthChart = ref(null)
+const userDistributionChart = ref(null)
+
+// 表格列配置
+const logColumns = [
+  {
+    title: '操作者',
+    dataIndex: 'operator_name',
+    key: 'operator_name',
+    width: 100
+  },
+  {
+    title: '请求方法',
+    dataIndex: 'method',
+    key: 'method',
+    width: 80
+  },
+  {
+    title: '请求路径',
+    dataIndex: 'uri',
+    key: 'uri',
+    width: 200,
+    ellipsis: true
+  },
+  {
+    title: '操作描述',
+    dataIndex: 'description',
+    key: 'description',
+    width: 150,
+    ellipsis: true
+  },
+  {
+    title: '状态',
+    dataIndex: 'is_success',
+    key: 'is_success',
+    width: 80
+  },
+  {
+    title: '耗时',
+    dataIndex: 'duration',
+    key: 'duration',
+    width: 80
+  },
+  {
+    title: 'IP地址',
+    dataIndex: 'ip',
+    key: 'ip',
+    width: 120
+  },
+  {
+    title: '操作时间',
+    dataIndex: 'created_at',
+    key: 'created_at',
+    width: 150
+  }
+]
+
+// 页面生命周期
+onMounted(async () => {
+  await loadDashboardData()
+  await nextTick()
+  initCharts()
+})
+
+// 加载仪表盘数据
+const loadDashboardData = async () => {
+  loading.value = true
+  try {
+    // 并行加载所有数据
+    const [statsRes, logsRes, growthRes, systemRes, distributionRes] = await Promise.all([
+      getStatistics(),
+      getRecentLogs({ limit: 10 }),
+      getUserGrowth(),
+      getSystemInfo(),
+      getUserDistribution()
+    ])
+
+    if (statsRes.code === 200) {
+      statistics.value = statsRes.result
+    }
+
+    if (logsRes.code === 200) {
+      recentLogs.value = logsRes.result
+    }
+
+    if (growthRes.code === 200) {
+      userGrowthData.value = growthRes.result
+    }
+
+    if (systemRes.code === 200) {
+      systemInfo.value = systemRes.result
+    }
+
+    if (distributionRes.code === 200) {
+      userDistributionData.value = distributionRes.result
+    }
+
+    // 加载内容统计数据
+    await loadContentStatistics()
+  } catch (error) {
+    notifyError(error, '加载数据失败')
+    console.error('Dashboard data loading error:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+// 加载内容统计数据
+const loadContentStatistics = async () => {
+  try {
+    // 并行获取统计数据
+    const [userRes, postRes, topicRes] = await Promise.all([
+      getUserStatistics(),
+      getPostStatistics(),
+      getTopicStatistics()
+    ])
+
+    // 处理用户统计数据
+    if (userRes.code === 200) {
+      userStatistics.value = userRes.result
+    } else {
+      // 如果API失败,使用默认值
+      userStatistics.value = {
+        today: 0,
+        week: 0,
+        month: 0,
+        lastMonth: 0
+      }
+    }
+
+    // 处理帖子统计数据
+    if (postRes.code === 200) {
+      postStatistics.value = postRes.result
+    } else {
+      postStatistics.value = {
+        today: 0,
+        week: 0,
+        month: 0,
+        lastMonth: 0
+      }
+    }
+
+    // 处理圈子统计数据
+    if (topicRes.code === 200) {
+      topicStatistics.value = topicRes.result
+    } else {
+      topicStatistics.value = {
+        today: 0,
+        week: 0,
+        month: 0,
+        lastMonth: 0
+      }
+    }
+  } catch (error) {
+    console.error('Content statistics loading error:', error)
+    // 错误时设置默认值
+    userStatistics.value = { today: 0, week: 0, month: 0, lastMonth: 0 }
+    postStatistics.value = { today: 0, week: 0, month: 0, lastMonth: 0 }
+    topicStatistics.value = { today: 0, week: 0, month: 0, lastMonth: 0 }
+  }
+}
+
+// 初始化图表
+const initCharts = () => {
+  initUserGrowthChart()
+  initUserDistributionChart()
+}
+
+// 初始化用户增长趋势图表
+const initUserGrowthChart = () => {
+  if (!userGrowthChart.value) return
+
+  const chart = echarts.init(userGrowthChart.value)
+
+  const option = {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'cross'
+      }
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      data: userGrowthData.value.map(item => item.label),
+      axisLine: {
+        lineStyle: {
+          color: '#e8e8e8'
+        }
+      }
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: {
+        lineStyle: {
+          color: '#e8e8e8'
+        }
+      }
+    },
+    series: [
+      {
+        name: '新增用户',
+        type: 'line',
+        smooth: true,
+        data: userGrowthData.value.map(item => item.count),
+        itemStyle: {
+          color: themeColors.primaryColor
+        },
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              {
+                offset: 0,
+                color: 'rgba(0, 82, 217, 0.3)'
+              },
+              {
+                offset: 1,
+                color: 'rgba(0, 82, 217, 0.05)'
+              }
+            ]
+          }
+        }
+      }
+    ]
+  }
+
+  chart.setOption(option)
+
+  // 响应式
+  window.addEventListener('resize', () => {
+    chart.resize()
+  })
+}
+
+// 初始化用户分布饼图
+const initUserDistributionChart = () => {
+  if (!userDistributionChart.value) return
+
+  const chart = echarts.init(userDistributionChart.value)
+
+  const option = {
+    tooltip: {
+      trigger: 'item',
+      formatter: '{a} <br/>{b}: {c} ({d}%)'
+    },
+    legend: {
+      orient: 'vertical',
+      left: 'left',
+      textStyle: {
+        fontSize: 12
+      }
+    },
+    series: [
+      {
+        name: '用户分布',
+        type: 'pie',
+        radius: ['40%', '70%'],
+        center: ['60%', '50%'],
+        data: userDistributionData.value.map(item => ({
+          name: item.name,
+          value: item.count
+        })),
+        emphasis: {
+          itemStyle: {
+            shadowBlur: 10,
+            shadowOffsetX: 0,
+            shadowColor: 'rgba(0, 0, 0, 0.5)'
+          }
+        },
+        label: {
+          show: false
+        }
+      }
+    ]
+  }
+
+  chart.setOption(option)
+
+  // 响应式
+  window.addEventListener('resize', () => {
+    chart.resize()
+  })
+}
+
+// 工具函数
+const getGrowthClass = (rate) => {
+  if (rate > 0) return 'growth-positive'
+  if (rate < 0) return 'growth-negative'
+  return 'growth-neutral'
+}
+
+const getMethodColor = (method) => {
+  const colors = {
+    'GET': 'blue',
+    'POST': 'green',
+    'PUT': 'orange',
+    'DELETE': 'red',
+    'PATCH': 'purple'
+  }
+  return colors[method] || 'default'
+}
+</script>
+
+<style lang="less" scoped>
+
+
+
+
+
+.chart-card {
+  border-radius: 5px;
+  box-shadow: 2px 0 8px #f5f5f5;
+  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+  position: relative;
+  overflow: hidden;
+  background: #fff;
+  border: none;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: 3px;
+    background: linear-gradient(90deg, var(--meow-color-primary), #366ef4, #618dff);
+    transform: scaleX(0);
+    transform-origin: left;
+    transition: transform 0.3s ease;
+  }
+
+  &:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 6px 20px rgba(0, 0, 0, 0.08);
+
+    &::before {
+      transform: scaleX(1);
+    }
+
+    :deep(.ant-card-head-title) {
+      color: var(--meow-color-primary);
+    }
+  }
+
+  :deep(.ant-card-head-title) {
+    transition: color 0.3s ease;
+  }
+}
+
+.chart-container {
+  height: 300px;
+  width: 100%;
+}
+
+.logs-card {
+  border-radius: 5px;
+  box-shadow: 2px 0 8px #f5f5f5;
+  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+  position: relative;
+  overflow: hidden;
+  background: #fff;
+  border: none;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: 3px;
+    background: linear-gradient(90deg, #722ed1, #9254de, #b37feb);
+    transform: scaleX(0);
+    transform-origin: left;
+    transition: transform 0.3s ease;
+  }
+
+  &:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 6px 20px rgba(0, 0, 0, 0.08);
+
+    &::before {
+      transform: scaleX(1);
+    }
+
+    :deep(.ant-card-head-title) {
+      color: #722ed1;
+    }
+  }
+
+  :deep(.ant-card-head-title) {
+    transition: color 0.3s ease;
+  }
+
+  :deep(.ant-table-tbody > tr) {
+    transition: all 0.3s ease;
+
+    &:hover {
+      background-color: #f8f9fa !important;
+      transform: scale(1.01);
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+    }
+  }
+}
+
+.system-info {
+  .info-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 10px 0;
+    border-bottom: 1px solid #f0f0f0;
+    transition: all 0.3s;
+
+    &:hover {
+      background-color: #fafafa;
+      margin: 0 -20px;
+      padding-left: 20px;
+      padding-right: 20px;
+    }
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .info-label {
+    font-size: 14px;
+    color: #666;
+    font-weight: 400;
+  }
+
+  .info-value {
+    font-size: 14px;
+    color: #333;
+    font-weight: 500;
+    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+  }
+}
+
+.stats-wrapper {
+  background-color: white;
+  border-radius: 5px;
+  padding: 20px;
+  margin-bottom: 10px;
+}
+
+.stats-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #616161;
+}
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px;
+  margin: -10px;
+  padding: 10px;
+}
+
+.content-stat-card {
+  text-align: center;
+  padding: 20px;
+}
+
+.content-stat-value {
+  font-size: 22px;
+  font-weight: 600;
+  line-height: 1;
+  margin-bottom: 8px;
+  color: #595959;
+}
+
+.content-stat-label {
+  font-size: 14px;
+  color: #999;
+  margin-bottom: 4px;
+  font-weight: 500;
+}
+
+.content-stat-desc {
+  font-size: 12px;
+  color: #666;
+  opacity: 0.8;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+
+  .desc-highlight {
+    color: #bfbfbf;
+    font-weight: 400;
+  }
+
+  .desc-number {
+    color: #8c8c8c;
+    font-weight: 400;
+    background: #f0f0f0;
+    padding: 2px 8px;
+    border-radius: 6px;
+    font-size: 12px;
+  }
+}
+
+</style>

+ 441 - 0
src/views/Login.vue

@@ -0,0 +1,441 @@
+<template>
+  <div class="login-container">
+    <!-- 主登录卡片 -->
+    <div class="login-card">
+      <!-- Logo区域 -->
+      <div class="logo-section">
+        <h1 class="brand-title">云喵圈子管理后台</h1>
+        <p class="brand-subtitle">开源系统论坛系统</p>
+        <div class="brand-description">
+          <p>专业的社区内容系统</p>
+          <p>简洁 · 高效 · 安全</p>
+        </div>
+      </div>
+
+      <!-- 登录表单 -->
+      <div class="form-section animate-on-enter animate-delay-1">
+        <div class="form-header">
+          <h2 class="form-title">欢迎回来</h2>
+          <p class="form-subtitle">请登录您的账户</p>
+        </div>
+
+        <a-form :model="loginForm" :rules="rules" @finish="handleLogin" layout="vertical" class="login-form">
+          <a-form-item name="username" class="animate-on-enter animate-delay-2">
+            <label class="input-label">用户名</label>
+            <a-input v-model:value="loginForm.username" placeholder="请输入用户名" size="large" :prefix="h(UserOutlined)" />
+          </a-form-item>
+
+          <a-form-item name="password" class="animate-on-enter animate-delay-3">
+            <label class="input-label">密码</label>
+            <a-input-password v-model:value="loginForm.password" placeholder="请输入密码" size="large"
+              :prefix="h(LockOutlined)" />
+          </a-form-item>
+
+          <a-form-item name="remember" class="animate-on-enter animate-delay-4">
+            <div class="form-options">
+              <a-checkbox v-model:checked="loginForm.remember" class="remember-checkbox">
+                记住密码
+              </a-checkbox>
+            </div>
+          </a-form-item>
+
+          <a-form-item class="animate-on-enter animate-delay-5">
+            <a-button type="primary" html-type="submit" size="large" :loading="loading" class="login-btn">
+              <span v-if="!loading">登录</span>
+              <span v-else>登录中...</span>
+            </a-button>
+          </a-form-item>
+        </a-form>
+
+
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, h, onMounted } from 'vue'
+import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
+import { useUserStore } from "@/stores/user.js"
+
+const userStore = useUserStore()
+const loading = ref(false)
+
+// 登录表单数据
+const loginForm = reactive({
+  username: '',
+  password: '',
+  remember: false
+})
+
+// 表单验证规则
+const rules = {
+  username: [
+    { required: true, message: '请输入用户名', trigger: 'blur' },
+    { max: 32, message: '用户名不能超过32个字符', trigger: 'blur' }
+  ],
+  password: [
+    { required: true, message: '请输入密码', trigger: 'blur' },
+
+  ]
+}
+
+
+
+// 登录处理
+const handleLogin = async (fromData) => {
+  await userStore.accountLogin(fromData, loading)
+}
+
+// 页面初始化
+onMounted(() => {
+  // 检查是否有记住的用户信息
+  const rememberedUser = localStorage.getItem('rememberedUser')
+  if (rememberedUser) {
+    try {
+      const userInfo = JSON.parse(rememberedUser)
+      if (userInfo.remember) {
+        loginForm.username = userInfo.username
+        loginForm.remember = true
+      }
+    } catch (error) {
+      console.error('解析记住的用户信息失败:', error)
+    }
+  }
+})
+</script>
+
+<style lang="less" scoped>
+// 主容器
+.login-container {
+  width: 100vw;
+  height: 100vh;
+  background: #f5f5f5;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20px;
+  box-sizing: border-box;
+}
+
+
+
+// 主登录卡片
+.login-card {
+  width: 1000px;
+  max-width: 95%;
+  height: 600px;
+  background: #ffffff;
+  border-radius: 16px;
+  box-shadow: 0 12px 48px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+  display: flex;
+  animation: slideUp 0.8s ease-out;
+}
+
+@keyframes slideUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+// Logo区域
+.logo-section {
+  flex: 1.2;
+  background: #fafafa;
+  border-right: 1px solid #e5e7eb;
+  padding: 60px 40px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
+
+
+
+.logo {
+  width: 160px;
+  height: auto;
+  max-height: 160px;
+  object-fit: contain;
+  margin-bottom: 32px;
+  border-radius: 12px;
+  padding: 0;
+  border: none;
+  transition: all 0.3s ease;
+
+  &:hover {
+    transform: translateY(-2px);
+  }
+}
+
+.brand-title {
+  font-size: 32px;
+  font-weight: 700;
+  color: #1f2937;
+  margin: 0 0 12px 0;
+  letter-spacing: 0.5px;
+}
+
+.brand-subtitle {
+  font-size: 16px;
+  color: #6b7280;
+  margin: 0 0 20px 0;
+  font-weight: 400;
+}
+
+.brand-description {
+  p {
+    font-size: 14px;
+    color: #9ca3af;
+    margin: 6px 0;
+    font-weight: 400;
+    line-height: 1.4;
+  }
+}
+
+// 表单区域
+.form-section {
+  flex: 0.8;
+  padding: 50px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  position: relative;
+}
+
+.form-header {
+  text-align: center;
+  margin-bottom: 32px;
+  animation: fadeInDown 0.8s ease-out 0.3s both;
+
+  .form-title {
+    font-size: 24px;
+    font-weight: 600;
+    color: #333333;
+    margin: 0 0 8px 0;
+    position: relative;
+
+    &::after {
+      content: '';
+      position: absolute;
+      bottom: -4px;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 40px;
+      height: 2px;
+      background: linear-gradient(90deg, var(--meow-color-primary), #366ef4);
+      border-radius: 1px;
+    }
+  }
+
+  .form-subtitle {
+    font-size: 14px;
+    color: #666666;
+    margin: 12px 0 0 0;
+  }
+}
+
+@keyframes fadeInDown {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+// 输入组件
+.login-form {
+  animation: fadeInUp 0.8s ease-out 0.5s both;
+
+  :deep(.ant-form-item) {
+    margin-bottom: 20px;
+    animation: slideInLeft 0.6s ease-out both;
+
+    &:nth-child(1) {
+      animation-delay: 0.6s;
+    }
+
+    &:nth-child(2) {
+      animation-delay: 0.7s;
+    }
+
+    &:nth-child(3) {
+      animation-delay: 0.8s;
+    }
+
+    &:nth-child(4) {
+      animation-delay: 0.9s;
+    }
+  }
+}
+
+@keyframes slideInLeft {
+  from {
+    opacity: 0;
+    transform: translateX(-30px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateX(0);
+  }
+}
+
+// 表单选项
+.form-options {
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.remember-checkbox {
+  :deep(.ant-checkbox) {
+    border-color: #d9d9d9;
+
+    &:hover {
+      border-color: var(--meow-color-primary);
+    }
+  }
+
+  :deep(.ant-checkbox-checked .ant-checkbox-inner) {
+    background-color: var(--meow-color-primary);
+    border-color: var(--meow-color-primary);
+  }
+
+  :deep(.ant-checkbox + span) {
+    color: #333333;
+    font-size: 14px;
+  }
+}
+
+// 登录按钮
+.login-btn {
+  width: 100%;
+  height: 48px;
+  border-radius: 8px;
+  font-size: 16px;
+  font-weight: 600;
+  background: var(--meow-color-primary);
+  border: none;
+  color: #ffffff;
+  transition: all 0.3s ease;
+  box-shadow: 0 4px 12px rgba(0, 82, 217, 0.3);
+  position: relative;
+  overflow: hidden;
+  animation: slideInUp 0.8s ease-out 1s both;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 100%;
+    height: 100%;
+    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
+    transition: left 0.5s ease;
+  }
+
+  &:hover {
+    background: var(--meow-color-primary-active);
+    box-shadow: 0 6px 20px rgba(0, 82, 217, 0.4);
+    transform: translateY(-2px);
+
+    &::before {
+      left: 100%;
+    }
+  }
+
+  &:active {
+    background: var(--meow-color-primary-active);
+    transform: translateY(0);
+    box-shadow: 0 2px 8px rgba(0, 82, 217, 0.3);
+  }
+
+  &:disabled {
+    background: #f5f5f5;
+    color: #bfbfbf;
+    cursor: not-allowed;
+    box-shadow: none;
+    transform: none;
+
+    &::before {
+      display: none;
+    }
+  }
+}
+
+@keyframes slideInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+
+
+// 响应式设计
+@media (max-width: 1200px) {
+  .login-card {
+    width: 900px;
+    height: 550px;
+  }
+}
+
+@media (max-width: 768px) {
+  .login-card {
+    flex-direction: column;
+    width: 100%;
+    height: auto;
+    margin: 10px;
+    border-radius: 12px;
+  }
+
+  .logo-section {
+    flex: none;
+    border-right: none;
+    border-bottom: 1px solid #e8e8e8;
+    padding: 40px 30px;
+  }
+
+  .form-section {
+    flex: none;
+    padding: 40px 30px;
+  }
+
+  .logo-container .logo {
+    width: 100px;
+    max-height: 100px;
+    padding: 15px;
+  }
+
+  .logo-container .brand-title {
+    font-size: 24px;
+  }
+
+  .logo-container .brand-subtitle {
+    font-size: 14px;
+  }
+
+  .form-header .form-title {
+    font-size: 20px;
+  }
+}
+</style>

+ 252 - 0
src/views/Profile.vue

@@ -0,0 +1,252 @@
+<template>
+  <div class="card-wrap animate-on-enter">
+    <div class="profile-header">
+      <div class="avatar-section">
+        <a-avatar 
+          :size="80" 
+          :src="userInfo.avatar" 
+          class="user-avatar"
+        >
+          {{ userAvatar }}
+        </a-avatar>
+
+      </div>
+      
+      <div class="user-basic-info">
+        <h2 class="user-name">{{ userInfo.username || '未设置' }}</h2>
+        <p class="user-role">{{ userInfo.role?.name || '普通用户' }}</p>
+        <div class="user-stats">
+          <div class="stat-item">
+            <span class="stat-label">最后登录时间</span>
+            <span class="stat-value">{{ formatTime(userInfo.last_login_time) }}</span>
+          </div>
+          <div class="stat-item">
+            <span class="stat-label">最后登录IP</span>
+            <span class="stat-value">{{ userInfo.last_login_ip || '未知' }}</span>
+          </div>
+        </div>
+      </div>
+      
+      <div class="profile-actions">
+        <a-button type="primary" @click="handleEditProfile">
+          <edit-outlined /> 编辑资料
+        </a-button>
+        <a-button @click="handleChangePassword">
+          <lock-outlined /> 修改密码
+        </a-button>
+      </div>
+    </div>
+    
+    <a-divider />
+    
+    <div class="profile-details">
+      <a-row :gutter="[24, 24]">
+        <a-col :span="12">
+          <a-card size="small" title="基本信息" class="info-card">
+            <div class="info-item">
+              <span class="info-label">用户名:</span>
+              <span class="info-value">{{ userInfo.username || '未设置' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">邮箱:</span>
+              <span class="info-value">{{ userInfo.email || '未设置' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">真实姓名:</span>
+              <span class="info-value">{{ userInfo.real_name || '未设置' }}</span>
+            </div>
+          </a-card>
+        </a-col>
+        
+        <a-col :span="12">
+          <a-card size="small" title="账户信息" class="info-card">
+            <div class="info-item">
+              <span class="info-label">角色:</span>
+              <a-tag :color="getRoleColor(userInfo.role?.name)">
+                {{ userInfo.role?.name || '普通用户' }}
+              </a-tag>
+            </div>
+            <div class="info-item">
+              <span class="info-label">状态:</span>
+              <a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
+                {{ userInfo.status === 1 ? '正常' : '禁用' }}
+              </a-tag>
+            </div>
+            <div class="info-item">
+              <span class="info-label">创建时间:</span>
+              <span class="info-value">{{ formatTime(userInfo.created_at) }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">更新时间:</span>
+              <span class="info-value">{{ formatTime(userInfo.updated_at) }}</span>
+            </div>
+          </a-card>
+        </a-col>
+      </a-row>
+    </div>
+    
+
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useUserStore } from '@/stores/user.js'
+import dayjs from 'dayjs'
+
+const router = useRouter()
+const userStore = useUserStore()
+
+// 计算属性
+const userInfo = computed(() => userStore.userInfo || {})
+const userAvatar = computed(() => {
+  if (userInfo.value.avatar) {
+    return userInfo.value.avatar
+  }
+  return userInfo.value.username?.charAt(0).toUpperCase() || 'U'
+})
+
+// 方法
+const formatTime = (time) => {
+  if (!time) return '未知'
+  return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
+}
+
+const getRoleColor = (roleName) => {
+  const colorMap = {
+    '超级管理员': 'red',
+    '管理员': 'blue',
+    '普通用户': 'green'
+  }
+  return colorMap[roleName] || 'default'
+}
+
+const handleEditProfile = () => {
+  router.push('/settings')
+}
+
+const handleChangePassword = () => {
+  router.push('/settings?tab=password')
+}
+</script>
+
+<style lang="less" scoped>
+
+.profile-header {
+  display: flex;
+  gap: 24px;
+  align-items: center;
+  margin-bottom: 24px;
+  padding: 20px;
+  background: #fafafa;
+  border-radius: 8px;
+}
+
+.avatar-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}
+
+.user-avatar {
+  background: linear-gradient(135deg, var(--meow-color-primary), var(--meow-color-primary-active));
+  font-size: 24px;
+  font-weight: 600;
+  color: white;
+}
+
+.user-basic-info {
+  flex: 1;
+}
+
+.user-name {
+  margin: 0 0 4px 0;
+  font-size: 20px;
+  font-weight: 600;
+  color: #1a1a1a;
+}
+
+.user-role {
+  margin: 0 0 12px 0;
+  font-size: 14px;
+  color: #666;
+}
+
+.user-stats {
+  display: flex;
+  gap: 24px;
+}
+
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.stat-label {
+  font-size: 12px;
+  color: #999;
+}
+
+.stat-value {
+  font-size: 13px;
+  color: #1a1a1a;
+  font-weight: 500;
+}
+
+.profile-details {
+  margin: 16px 0;
+}
+
+.info-card {
+  border-radius: 6px;
+}
+
+.info-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 6px 0;
+  border-bottom: 1px solid #f5f5f5;
+  
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.info-label {
+  font-size: 13px;
+  color: #666;
+}
+
+.info-value {
+  font-size: 13px;
+  color: #1a1a1a;
+}
+
+.profile-actions {
+  display: flex;
+  gap: 12px;
+}
+
+
+
+@media (max-width: 768px) {
+  .profile-header {
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+  }
+  
+  .user-stats {
+    justify-content: center;
+  }
+  
+  .profile-actions {
+    flex-direction: column;
+    align-items: center;
+  }
+}
+</style>

+ 505 - 0
src/views/Settings.vue

@@ -0,0 +1,505 @@
+<template>
+  <div class="card-wrap animate-on-enter">
+    <a-tabs v-model:activeKey="activeTab" type="card" class="settings-tabs">
+          <a-tab-pane key="profile" tab="个人信息">
+            <div class="tab-content">
+              <a-form
+                ref="profileFormRef"
+                :model="profileForm"
+                :rules="profileRules"
+                layout="vertical"
+                class="profile-form"
+              >
+                <a-row :gutter="24">
+                  <a-col :span="12">
+                    <a-form-item label="用户名" name="username">
+                      <a-input 
+                        v-model:value="profileForm.username" 
+                        placeholder="请输入用户名"
+                        :disabled="true"
+                      />
+                      <div class="form-tip">用户名不可修改</div>
+                    </a-form-item>
+                  </a-col>
+                  
+                  <a-col :span="12">
+                    <a-form-item label="真实姓名" name="real_name">
+                      <a-input 
+                        v-model:value="profileForm.real_name" 
+                        placeholder="请输入真实姓名"
+                      />
+                    </a-form-item>
+                  </a-col>
+                  
+                  <a-col :span="12">
+                    <a-form-item label="邮箱" name="email">
+                      <a-input 
+                        v-model:value="profileForm.email" 
+                        placeholder="请输入邮箱地址"
+                        type="email"
+                      />
+                    </a-form-item>
+                  </a-col>
+                  
+
+                  
+                  <a-col :span="24">
+                    <a-form-item label="个人简介" name="bio">
+                      <a-textarea 
+                        v-model:value="profileForm.bio" 
+                        placeholder="请输入个人简介"
+                        :rows="4"
+                        :maxlength="200"
+                        show-count
+                      />
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+                
+                <div class="form-actions">
+                  <a-button type="primary" @click="handleUpdateProfile" :loading="profileLoading">
+                    <save-outlined /> 保存修改
+                  </a-button>
+                  <a-button @click="handleResetProfile">
+                    <redo-outlined /> 重置
+                  </a-button>
+                </div>
+              </a-form>
+            </div>
+          </a-tab-pane>
+          
+          <a-tab-pane key="password" tab="修改密码">
+            <div class="tab-content">
+              <a-form
+                ref="passwordFormRef"
+                :model="passwordForm"
+                :rules="passwordRules"
+                layout="vertical"
+                class="password-form"
+              >
+                <a-row :gutter="24">
+                  <a-col :span="12">
+                    <a-form-item label="当前密码" name="current_password">
+                      <a-input-password 
+                        v-model:value="passwordForm.current_password" 
+                        placeholder="请输入当前密码"
+                        autocomplete="current-password"
+                      />
+                    </a-form-item>
+                    
+                    <a-form-item label="新密码" name="new_password">
+                      <a-input-password 
+                        v-model:value="passwordForm.new_password" 
+                        placeholder="请输入新密码"
+                        autocomplete="new-password"
+                      />
+                      <div class="form-tip">
+                        密码必须包含字母、数字和特殊字符,长度6-20位
+                      </div>
+                    </a-form-item>
+                    
+                    <a-form-item label="确认新密码" name="confirm_password">
+                      <a-input-password 
+                        v-model:value="passwordForm.confirm_password" 
+                        placeholder="请再次输入新密码"
+                        autocomplete="new-password"
+                      />
+                    </a-form-item>
+                  </a-col>
+                  
+                  <a-col :span="12">
+                    <div class="password-tips">
+                      <h4>密码安全建议:</h4>
+                      <ul>
+                        <li>密码长度至少6位,建议8位以上</li>
+                        <li>包含大小写字母、数字和特殊字符</li>
+                        <li>不要使用常见的密码组合</li>
+                        <li>定期更换密码</li>
+                        <li>不要在多个网站使用相同密码</li>
+                      </ul>
+                    </div>
+                  </a-col>
+                </a-row>
+                
+                <div class="form-actions">
+                  <a-button type="primary" @click="handleChangePassword" :loading="passwordLoading">
+                    <lock-outlined /> 修改密码
+                  </a-button>
+                  <a-button @click="handleResetPassword">
+                    <redo-outlined /> 重置
+                  </a-button>
+                </div>
+              </a-form>
+            </div>
+          </a-tab-pane>
+          
+          <a-tab-pane key="security" tab="安全设置">
+            <div class="tab-content">
+              <div class="security-section">
+                <h3>登录安全</h3>
+                <div class="security-item">
+                  <div class="security-info">
+                    <h4>最后登录时间</h4>
+                    <p>{{ formatTime(userInfo.last_login_time) }}</p>
+                  </div>
+                </div>
+                
+                <div class="security-item">
+                  <div class="security-info">
+                    <h4>最后登录IP</h4>
+                    <p>{{ userInfo.last_login_ip || '未知' }}</p>
+                  </div>
+                </div>
+                
+                <a-divider />
+                
+                <h3>账户状态</h3>
+                <div class="security-item">
+                  <div class="security-info">
+                    <h4>账户状态</h4>
+                    <a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
+                      {{ userInfo.status === 1 ? '正常' : '禁用' }}
+                    </a-tag>
+                  </div>
+                </div>
+                
+                <div class="security-item">
+                  <div class="security-info">
+                    <h4>角色权限</h4>
+                    <a-tag color="blue">
+                      {{ userInfo.role?.name || '普通用户' }}
+                    </a-tag>
+                  </div>
+                </div>
+                
+                <a-divider />
+                
+                <div class="danger-zone">
+                  <h3>危险操作</h3>
+                  <div class="security-item">
+                    <div class="security-info">
+                      <h4>注销账户</h4>
+                      <p>注销后将无法恢复,请谨慎操作</p>
+                    </div>
+                    <a-button danger @click="handleDeleteAccount">
+                      <delete-outlined /> 注销账户
+                    </a-button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </a-tab-pane>
+        </a-tabs>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useUserStore } from '@/stores/user.js'
+import { message, Modal } from 'ant-design-vue'
+import { updateProfile, changePassword } from '@/api/auth.js'
+import dayjs from 'dayjs'
+
+const route = useRoute()
+const router = useRouter()
+const userStore = useUserStore()
+
+// 响应式数据
+const activeTab = ref('profile')
+const profileLoading = ref(false)
+const passwordLoading = ref(false)
+const profileFormRef = ref()
+const passwordFormRef = ref()
+
+// 表单数据
+const profileForm = reactive({
+  username: '',
+  real_name: '',
+  email: '',
+
+  bio: ''
+})
+
+const passwordForm = reactive({
+  current_password: '',
+  new_password: '',
+  confirm_password: ''
+})
+
+// 计算属性
+const userInfo = computed(() => userStore.userInfo || {})
+
+// 表单验证规则
+const profileRules = {
+  real_name: [
+    { max: 20, message: '真实姓名不能超过20个字符', trigger: 'blur' }
+  ],
+  email: [
+    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
+  ],
+
+}
+
+const passwordRules = {
+  current_password: [
+    { required: true, message: '请输入当前密码', trigger: 'blur' }
+  ],
+  new_password: [
+    { required: true, message: '请输入新密码', trigger: 'blur' },
+    { min: 8, max: 72, message: '密码长度为8-72位', trigger: 'blur' },
+    { 
+      pattern: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]+$/, 
+      message: '密码必须包含字母、数字和特殊字符', 
+      trigger: 'blur' 
+    }
+  ],
+  confirm_password: [
+    { required: true, message: '请确认新密码', trigger: 'blur' },
+    {
+      validator: (rule, value) => {
+        if (value !== passwordForm.new_password) {
+          return Promise.reject('两次输入的密码不一致')
+        }
+        return Promise.resolve()
+      },
+      trigger: 'blur'
+    }
+  ]
+}
+
+// 方法
+const formatTime = (time) => {
+  if (!time) return '未知'
+  return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
+}
+
+const initProfileForm = () => {
+  Object.assign(profileForm, {
+    username: userInfo.value.username || '',
+    real_name: userInfo.value.real_name || '',
+    email: userInfo.value.email || '',
+
+    bio: userInfo.value.bio || ''
+  })
+}
+
+const handleUpdateProfile = async () => {
+  try {
+    await profileFormRef.value.validate()
+    profileLoading.value = true
+    
+    const response = await updateProfile(profileForm)
+    if (response.code === 200) {
+      message.success('个人信息更新成功')
+      // 更新store中的用户信息
+      userStore.userInfo = { ...userStore.userInfo, ...profileForm }
+    } else {
+      message.error(response.message || '更新失败')
+    }
+  } catch (error) {
+    console.error('更新个人信息失败:', error)
+  } finally {
+    profileLoading.value = false
+  }
+}
+
+const handleResetProfile = () => {
+  initProfileForm()
+  profileFormRef.value?.clearValidate()
+}
+
+const handleChangePassword = async () => {
+  try {
+    await passwordFormRef.value.validate()
+    passwordLoading.value = true
+    
+    const response = await changePassword(passwordForm)
+    if (response.code === 200) {
+      message.success('密码修改成功,请重新登录')
+      // 清空表单
+      Object.assign(passwordForm, {
+        current_password: '',
+        new_password: '',
+        confirm_password: ''
+      })
+      // 延迟跳转到登录页
+      setTimeout(() => {
+        userStore.outLogin()
+      }, 1500)
+    } else {
+      message.error(response.message || '密码修改失败')
+    }
+  } catch (error) {
+    console.error('修改密码失败:', error)
+  } finally {
+    passwordLoading.value = false
+  }
+}
+
+const handleResetPassword = () => {
+  Object.assign(passwordForm, {
+    current_password: '',
+    new_password: '',
+    confirm_password: ''
+  })
+  passwordFormRef.value?.clearValidate()
+}
+
+const handleDeleteAccount = () => {
+  Modal.confirm({
+    title: '确认注销账户?',
+    content: '注销后账户数据将被永久删除,此操作不可恢复!',
+    okText: '确认注销',
+    okType: 'danger',
+    cancelText: '取消',
+    onOk() {
+      message.info('账户注销功能暂未开放')
+    }
+  })
+}
+
+// 监听路由参数
+watch(
+  () => route.query.tab,
+  (newTab) => {
+    if (newTab && ['profile', 'password', 'security'].includes(newTab)) {
+      activeTab.value = newTab
+    }
+  },
+  { immediate: true }
+)
+
+// 监听用户信息变化
+watch(
+  () => userInfo.value,
+  () => {
+    initProfileForm()
+  },
+  { immediate: true, deep: true }
+)
+
+onMounted(() => {
+  initProfileForm()
+})
+</script>
+
+<style lang="less" scoped>
+.settings-tabs {
+  :deep(.ant-tabs-card .ant-tabs-tab) {
+    border-radius: 6px 6px 0 0;
+    
+    &.ant-tabs-tab-active {
+      background: #fff;
+    }
+  }
+}
+
+.tab-content {
+  padding: 20px;
+}
+
+.profile-form,
+.password-form {
+  max-width: 600px;
+}
+
+.form-tip {
+  font-size: 12px;
+  color: #999;
+  margin-top: 4px;
+}
+
+.form-actions {
+  display: flex;
+  gap: 12px;
+  margin-top: 20px;
+}
+
+.password-tips {
+  background: #f6f8fa;
+  border: 1px solid #e1e4e8;
+  border-radius: 6px;
+  padding: 16px;
+  
+  h4 {
+    margin: 0 0 8px 0;
+    color: #1a1a1a;
+    font-weight: 600;
+  }
+  
+  ul {
+    margin: 0;
+    padding-left: 16px;
+    
+    li {
+      margin-bottom: 6px;
+      color: #666;
+      font-size: 13px;
+      line-height: 1.4;
+    }
+  }
+}
+
+.security-section {
+  max-width: 600px;
+  
+  h3 {
+    margin: 0 0 12px 0;
+    color: #1a1a1a;
+    font-weight: 600;
+    font-size: 15px;
+  }
+}
+
+.security-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 0;
+  border-bottom: 1px solid #f0f0f0;
+  
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.security-info {
+  flex: 1;
+  
+  h4 {
+    margin: 0 0 4px 0;
+    color: #1a1a1a;
+    font-weight: 500;
+    font-size: 13px;
+  }
+  
+  p {
+    margin: 0;
+    color: #666;
+    font-size: 13px;
+  }
+}
+
+.danger-zone {
+  margin-top: 24px;
+  padding-top: 16px;
+  border-top: 2px solid #ff4d4f;
+  
+  h3 {
+    color: #ff4d4f;
+  }
+}
+
+@media (max-width: 768px) {
+  .tab-content {
+    padding: 12px;
+  }
+  
+  .security-item {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
+  }
+}
+</style>

+ 63 - 0
src/views/config/EmailConfig.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="email-config-page">
+    <a-card title="阿里云邮件推送配置">
+      <template #extra><a-button :loading="loading" @click="loadConfig">刷新</a-button></template>
+      <a-alert message="PC邮箱登录和注册共用此配置。请使用已有授权的发信地址,生产环境应开启SSL校验。" type="info" show-icon class="config-tip" />
+      <a-spin :spinning="loading">
+        <a-form :model="form" layout="vertical" @finish="onSubmit">
+          <a-form-item label="AccessKey ID" name="access_key_id" :rules="[{ required: true, message: '请输入AccessKey ID' }]"><a-input v-model:value="form.access_key_id" :maxlength="100" autocomplete="off" /></a-form-item>
+          <a-form-item label="AccessKey Secret" name="access_key_secret" :rules="[{ required: true, message: '请输入AccessKey Secret' }]"><a-input-password v-model:value="form.access_key_secret" :maxlength="200" autocomplete="new-password" /></a-form-item>
+          <a-form-item label="发信地址" name="account_name" :rules="[{ required: true, type: 'email', message: '请输入正确的发信地址' }]"><a-input v-model:value="form.account_name" :maxlength="100" placeholder="no-reply@example.com" /></a-form-item>
+          <a-form-item label="发信别名" name="from_alias"><a-input v-model:value="form.from_alias" :maxlength="15" placeholder="留空使用站点名称" /></a-form-item>
+          <a-form-item label="允许回复"><a-switch v-model:checked="form.reply_to_address" /></a-form-item>
+          <a-form-item label="SSL校验"><a-switch v-model:checked="form.ssl_verify" /></a-form-item>
+          <a-button type="primary" html-type="submit" :loading="saving" :disabled="loading || !loaded">保存配置</a-button>
+        </a-form>
+      </a-spin>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { message } from 'ant-design-vue'
+import { getPlatformConfig, saveConfig } from '@/api/config'
+
+const loading = ref(false)
+const saving = ref(false)
+const loaded = ref(false)
+const form = reactive({
+  access_key_id: '', access_key_secret: '', account_name: '', from_alias: '',
+  reply_to_address: false, ssl_verify: true
+})
+
+async function loadConfig() {
+  loading.value = true
+  loaded.value = false
+  try {
+    const response = await getPlatformConfig({ key: 'aliyun_direct_mail' })
+    if (response.code !== 200) return
+    Object.assign(form, response.result.value)
+    loaded.value = true
+  } finally {
+    loading.value = false
+  }
+}
+
+async function onSubmit() {
+  saving.value = true
+  try {
+    const response = await saveConfig({ key: 'aliyun_direct_mail', name: '阿里云邮件推送配置', value: { ...form } })
+    if (response.code === 200) message.success('配置已保存')
+  } finally {
+    saving.value = false
+  }
+}
+
+onMounted(loadConfig)
+</script>
+
+<style scoped>
+.email-config-page { max-width: 880px; }
+.config-tip { margin-bottom: 24px; }
+</style>

+ 213 - 0
src/views/config/MnpWechat.vue

@@ -0,0 +1,213 @@
+<template>
+  <div class="wechat-mnp-config">
+    <div class="card-wrap mb10 animate-on-enter">
+      <a-space wrap>
+        <a-button :icon="h(ReloadOutlined)" @click="onRefresh" :loading="loading">
+          刷新
+        </a-button>
+      </a-space>
+    </div>
+
+    <div class="card-wrap mb10 animate-on-enter animate-delay-1">
+      <a-alert :message="formState.description" type="info" show-icon />
+    </div>
+
+    <div class="card-wrap animate-on-enter animate-delay-2">
+      <a-spin :spinning="loading">
+        <a-form ref="formRef" :model="formState" :label-col="{ span: 4 }" :wrapper-col="{ span: 16 }" :rules="rules"
+          @finish="onSubmit" @finishFailed="onFinishFailed">
+          <a-form-item label="AppID" :name="['value', 'app_id']">
+            <a-input v-model:value="formState.value.app_id" placeholder="请输入微信小程序AppID" />
+          </a-form-item>
+
+          <a-form-item label="AppSecret" :name="['value', 'secret']">
+            <a-input-password v-model:value="formState.value.secret" placeholder="请输入微信小程序AppSecret" />
+          </a-form-item>
+
+          <a-form-item label="Token" :name="['value', 'token']">
+            <a-input v-model:value="formState.value.token" placeholder="请输入消息推送Token" />
+          </a-form-item>
+
+          <a-form-item label="EncodingAESKey" :name="['value', 'aes_key']">
+            <a-input v-model:value="formState.value.aes_key" placeholder="请输入消息加解密密钥" />
+          </a-form-item>
+
+          <a-form-item :wrapper-col="{ offset: 4, span: 16 }">
+            <a-button type="primary" :icon="h(SaveOutlined)" html-type="submit" :loading="saveLoading">
+              保存配置
+            </a-button>
+          </a-form-item>
+        </a-form>
+      </a-spin>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, reactive, onMounted, h } from 'vue'
+import { message } from 'ant-design-vue'
+import { SaveOutlined, ReloadOutlined } from '@ant-design/icons-vue'
+import { getPlatformConfig, saveConfig } from '@/api/config'
+
+// 响应式数据
+const formRef = ref()
+const loading = ref(false)
+const saveLoading = ref(false)
+
+// 表单数据
+const formState = reactive({
+  id: '',
+  name: '微信小程序配置',
+  key: 'wechat_mnp',
+  value: {
+    app_id: '',
+    secret: '',
+    token: '',
+    aes_key: ''
+  },
+  description: '微信小程序基础配置信息'
+})
+
+// 表单验证规则
+const rules = {
+  value: {
+    app_id: [
+      { required: true, message: '请输入微信小程序AppID', trigger: 'change' },
+    ],
+    secret: [
+      { required: true, message: '请输入微信小程序AppSecret', trigger: 'change' }
+    ],
+    token: [
+      { required: true, message: '请输入消息推送Token', trigger: 'change' }
+    ],
+    aes_key: [
+      { required: true, message: '请输入消息加解密密钥', trigger: 'change' }
+    ]
+  }
+}
+
+// 方法
+const loadConfig = async () => {
+  loading.value = true
+  try {
+    const response = await getPlatformConfig({
+      key: 'wechat_mnp'
+    })
+
+    if (response.code === 200 && response.result) {
+      // 如果有配置数据,则填充表单
+      const config = response.result
+
+      // 处理 value,确保它是一个对象
+      const configValue = config.value || {}
+
+      Object.assign(formState, {
+        id: config.id || '',
+        name: config.name || '微信小程序配置',
+        key: config.key || 'wechat_mnp',
+        value: {
+          app_id: configValue.app_id || '',
+          secret: configValue.secret || '',
+          token: configValue.token || '',
+          aes_key: configValue.aes_key || ''
+        },
+        description: config.description || '微信小程序基础配置信息'
+      })
+    }
+  } catch (error) {
+    console.error('加载配置失败:', error)
+    notifyError(error, '加载配置失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const onSubmit = async (values) => {
+  saveLoading.value = true
+  try {
+    const submitData = {
+      name: formState.name,
+      key: formState.key,
+      value: values.value
+    }
+
+    const response = await saveConfig(submitData)
+    if (response.code === 200) {
+      message.success('保存成功')
+      // 重新加载配置
+      await loadConfig()
+    } else {
+      message.error(response.message || '保存失败')
+    }
+  } catch (error) {
+    console.error('保存失败:', error)
+    notifyError(error, '保存失败')
+  } finally {
+    saveLoading.value = false
+  }
+}
+
+const onFinishFailed = () => {
+  message.error('请检查表单填写是否正确')
+}
+
+const onRefresh = () => {
+  loadConfig()
+}
+
+// 生命周期
+onMounted(() => {
+  loadConfig()
+})
+</script>
+
+<style scoped>
+.wechat-mnp-config {
+  .card-wrap {
+    background: #fff;
+    border-radius: 8px;
+    padding: 24px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
+    h3 {
+      margin-bottom: 16px;
+      color: #333;
+      font-weight: 600;
+    }
+  }
+
+  .mb10 {
+    margin-bottom: 16px;
+  }
+
+  .mt10 {
+    margin-top: 16px;
+  }
+
+  .animate-on-enter {
+    animation: fadeInUp 0.6s ease-out;
+  }
+
+  .animate-delay-1 {
+    animation-delay: 0.1s;
+  }
+
+  .animate-delay-2 {
+    animation-delay: 0.2s;
+  }
+
+  @keyframes fadeInUp {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+
+    to {
+      opacity: 1;
+      transform: translateY(0);
+    }
+  }
+}
+</style>

+ 315 - 0
src/views/content/Category.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="categories-container">
+    <!-- 搜索表单 -->
+    <div class="mb10">
+      <SearchForm
+        :searchFields="searchItems"
+        :maxFirstRowItems="3"
+        @search="handleSearch"
+        @reset="handleReset"
+      />
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="mb10">
+      <div class="table-operations">
+        <a-button
+          type="primary"
+          @click="addCategory"
+        >
+          <PlusOutlined /> 新增类目
+        </a-button>
+        <a-button
+          type="primary"
+          danger
+          :disabled="!hasSelected"
+          @click="handleBatchDelete"
+        >
+          <DeleteOutlined /> 批量删除
+        </a-button>
+        <span style="margin-left: 8px;" v-if="hasSelected">
+          已选择 {{ selectedRowKeys.length }} 项
+        </span>
+      </div>
+    </div>
+
+    <!-- 数据列表 -->
+    <div class="card-wrap">
+      <DataList
+        ref="dataListRef"
+        :api="(params) => categoryApi.getListByPage(params)"
+        :deleteApi="categoryApi.deleteCategory"
+        :columns="columns"
+        :rowSelection="rowSelection"
+        @action="onAction"
+      >
+        <!-- 自定义列插槽 -->
+        <template #name="{ record }">
+          <div class="category-name">
+            <div class="name-text">{{ record.name }}</div>
+          </div>
+        </template>
+
+
+
+        <template #created_at="{ record }">
+          <div>{{ formatDate(record.created_at) }}</div>
+        </template>
+
+        <template #updated_at="{ record }">
+          <div>{{ formatDate(record.updated_at) }}</div>
+        </template>
+
+        <template #action="{ record }">
+          <a-space>
+            <a-button type="link" size="small" @click="editCategory(record)">
+              编辑
+            </a-button>
+            <a-popconfirm
+              title="确定要删除这个类目吗?"
+              @confirm="handleDelete(record.id)"
+            >
+              <a-button type="link" size="small" danger>
+                删除
+              </a-button>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </DataList>
+    </div>
+
+    <!-- 新增/编辑类目模态框 -->
+    <a-modal
+      v-model:open="editModalVisible"
+      :title="editForm.id ? '编辑类目' : '新增类目'"
+      width="600px"
+      @ok="handleSave"
+      :confirmLoading="saveLoading"
+    >
+      <a-form
+        ref="formRef"
+        :model="editForm"
+        :rules="editRules"
+        layout="vertical"
+      >
+        <a-form-item label="类目名称" name="name">
+          <a-input v-model:value="editForm.name" placeholder="请输入类目名称" />
+        </a-form-item>
+
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, reactive, computed, onMounted } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
+import DataList from '@/components/DataList.vue'
+import SearchForm from '@/components/SearchForm.vue'
+import { categoryApi } from '@/api/content'
+import { formatDate } from '@/utils/date'
+
+// 响应式数据
+const dataListRef = ref()
+const formRef = ref()
+const selectedRowKeys = ref([])
+const editModalVisible = ref(false)
+const saveLoading = ref(false)
+
+// 编辑表单
+const editForm = reactive({
+  id: null,
+  name: ''
+})
+
+// 表单验证规则
+const editRules = {
+  name: [
+    { required: true, message: '请输入类目名称', trigger: 'blur' },
+    { max: 50, message: '类目名称不能超过50个字符', trigger: 'blur' }
+  ]
+}
+
+// 搜索表单配置
+const searchItems = ref([
+  {
+    label: '类目名称',
+    prop: 'name',
+    type: 'input',
+    placeholder: '请输入类目名称'
+  },
+  {
+    label: '创建时间',
+    prop: 'date_range',
+    type: 'daterange',
+    placeholder: ['开始日期', '结束日期']
+  }
+])
+
+// 表格列配置
+const columns = [
+  {
+    title: '类目名称',
+    dataIndex: 'name',
+    width: 200,
+    slot: true
+  },
+
+  {
+    title: '创建时间',
+    dataIndex: 'created_at',
+    width: 180,
+    slot: true,
+    sorter: true
+  },
+  {
+    title: '更新时间',
+    dataIndex: 'updated_at',
+    width: 180,
+    slot: true,
+    sorter: true
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 150,
+    type: 'action',
+    options: [
+      { title: '编辑', key: 'edit' },
+      { title: '删除', key: 'delete', type: 'delete' }
+    ]
+  }
+]
+
+// 行选择配置
+const rowSelection = {
+  selectedRowKeys: selectedRowKeys,
+  onChange: (keys) => {
+    selectedRowKeys.value = keys
+  }
+}
+
+// 计算属性
+const hasSelected = computed(() => selectedRowKeys.value.length > 0)
+
+// 方法
+const onAction = (item, record) => {
+  if (item.key === 'edit') {
+    editCategory(record)
+  }
+}
+
+const handleSearch = (params) => {
+  dataListRef.value.search(params)
+}
+
+const handleReset = () => {
+  dataListRef.value.reset()
+}
+
+const addCategory = () => {
+  editForm.id = null
+  editForm.name = ''
+  editModalVisible.value = true
+}
+
+const editCategory = (record) => {
+  editForm.id = record.id
+  editForm.name = record.name
+  editModalVisible.value = true
+}
+
+const handleDelete = async (id) => {
+  try {
+    await categoryApi.deleteCategory({ id })
+    message.success('删除成功')
+    dataListRef.value.refreshData()
+  } catch (error) {
+    notifyError(error, '删除失败:' + error.message)
+  }
+}
+
+const handleBatchDelete = () => {
+  if (selectedRowKeys.value.length === 0) {
+    message.warning('请选择要删除的类目')
+    return
+  }
+
+  Modal.confirm({
+    title: '确认删除',
+    content: `确定要删除选中的 ${selectedRowKeys.value.length} 个类目吗?`,
+    onOk: async () => {
+      try {
+        await categoryApi.batchDeleteCategories({ ids: selectedRowKeys.value })
+        message.success('批量删除成功')
+        selectedRowKeys.value = []
+        dataListRef.value.refreshData()
+      } catch (error) {
+        notifyError(error, '批量删除失败:' + error.message)
+      }
+    }
+  })
+}
+
+const handleSave = async () => {
+  try {
+    await formRef.value.validate()
+    saveLoading.value = true
+    
+    if (editForm.id) {
+      await categoryApi.updateCategory(editForm)
+      message.success('更新成功')
+    } else {
+      await categoryApi.createCategory(editForm)
+      message.success('创建成功')
+    }
+    
+    editModalVisible.value = false
+    dataListRef.value.refreshData()
+  } catch (error) {
+    if (error.message) {
+      notifyError(error, '保存失败:' + error.message)
+    }
+  } finally {
+    saveLoading.value = false
+  }
+}
+
+
+
+// 生命周期
+onMounted(() => {
+  // 初始化数据
+})
+</script>
+
+<style scoped>
+.categories-container {
+  padding: 24px;
+}
+
+.category-name {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.name-text {
+  font-weight: 500;
+  color: var(--meow-color-primary);
+  cursor: pointer;
+}
+
+
+
+.table-operations {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+
+</style>

+ 526 - 0
src/views/content/Post.vue

@@ -0,0 +1,526 @@
+<template>
+  <div class="posts-container">
+    <!-- 搜索表单 -->
+    <div class="mb10">
+      <SearchForm :searchFields="searchItems" :maxFirstRowItems="3" @search="handleSearch" @reset="handleReset" />
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="mb10">
+      <div class="table-operations">
+        <a-button type="primary" danger :disabled="!hasSelected" @click="handleBatchDelete">
+          <DeleteOutlined /> 批量删除
+        </a-button>
+        <span style="margin-left: 8px;" v-if="hasSelected">
+          已选择 {{ selectedRowKeys.length }} 项
+        </span>
+      </div>
+    </div>
+
+    <!-- 数据列表 -->
+    <div class="card-wrap">
+      <DataList ref="dataListRef" :api="(params) => postApi.getListByPage(params)" :deleteApi="postApi.deletePost"
+        :columns="columns" :rowSelection="rowSelection" @action="onAction">
+        <!-- 自定义列插槽 -->
+
+        <template #content="{ record }">
+          <div class="post-content-wrapper">
+            <div class="post-text">
+              {{ record.content ? record.content.substring(0, 100) + (record.content.length > 100 ? '...' : '') : '无内容'
+              }}
+            </div>
+            <div v-if="record.media && parseMedia(record.media).length > 0" class="post-images">
+              <a-image-preview-group>
+                <div class="image-grid">
+                  <a-image v-for="(media, index) in parseMedia(record.media).filter(m => m && isImage(m)).slice(0, 9)"
+                    :key="`img-${record.id}-${index}`" :src="getImageUrl(media)" :width="60" :height="60"
+                    class="grid-image" :style="{ objectFit: 'cover' }" :preview="{
+                      src: getImageUrl(media)
+                    }" />
+                </div>
+              </a-image-preview-group>
+            </div>
+          </div>
+        </template>
+
+        <template #user="{ record }">
+          <div class="user-info">
+            <a-avatar :src="getImageUrl(record.user?.avatar)" size="small">
+              {{ record.user?.nickname?.charAt(0) }}
+            </a-avatar>
+            <div class="user-details">
+              <div>{{ record.user?.nickname || '未知用户' }}</div>
+            </div>
+          </div>
+        </template>
+
+        <template #topic="{ record }">
+          <div class="topic-info">
+            <a-avatar :src="getImageUrl(record.topic?.cover_image)" size="small" shape="square">
+              {{ record.topic?.name?.charAt(0) }}
+            </a-avatar>
+            <div class="topic-details">
+              <div class="topic-name">{{ record.topic?.name || '未知圈子' }}</div>
+            </div>
+          </div>
+        </template>
+
+        <template #created_at="{ record }">
+          <div>{{ formatDate(record.created_at) }}</div>
+        </template>
+
+        <template #action="{ record }">
+          <a-space>
+            <a-button type="link" size="small" @click="viewDetail(record)">
+              查看详情
+            </a-button>
+            <a-popconfirm title="确定要删除这个帖子吗?" @confirm="handleDelete(record.id)">
+              <a-button type="link" size="small" danger>
+                删除
+              </a-button>
+            </a-popconfirm>
+          </a-space>
+        </template>
+      </DataList>
+    </div>
+
+    <!-- 帖子详情模态框 -->
+    <a-modal v-model:open="detailModalVisible" title="帖子详情" width="800px" :footer="null">
+      <div v-if="currentPost" class="post-detail">
+        <a-descriptions :column="2" bordered>
+          <a-descriptions-item label="标题" :span="2">
+            {{ currentPost.title }}
+          </a-descriptions-item>
+          <a-descriptions-item label="内容" :span="2">
+            <div class="post-content-detail">{{ currentPost.content }}</div>
+          </a-descriptions-item>
+          <a-descriptions-item label="类型">
+            <a-tag v-if="Number(currentPost.type) === 1" color="blue">图文</a-tag>
+            <a-tag v-else-if="Number(currentPost.type) === 2" color="orange">视频</a-tag>
+            <a-tag v-else-if="Number(currentPost.type) === 4" color="purple">投票</a-tag>
+          </a-descriptions-item>
+          <a-descriptions-item label="发布者">
+            {{ currentPost.user?.nickname || '未知用户' }}
+          </a-descriptions-item>
+          <a-descriptions-item label="所属圈子">
+            {{ currentPost.topic?.name || '未知圈子' }}
+          </a-descriptions-item>
+          <a-descriptions-item label="地址">
+            {{ currentPost.address || '无' }}
+          </a-descriptions-item>
+          <a-descriptions-item label="浏览数">
+            {{ currentPost.see_num }}
+          </a-descriptions-item>
+          <a-descriptions-item label="点赞数">
+            {{ currentPost.thumb_num }}
+          </a-descriptions-item>
+          <a-descriptions-item label="收藏数">
+            {{ currentPost.collect_num }}
+          </a-descriptions-item>
+          <a-descriptions-item label="评论数">
+            {{ currentPost.comment_num }}
+          </a-descriptions-item>
+          <a-descriptions-item label="创建时间">
+            {{ formatDate(currentPost.created_at) }}
+          </a-descriptions-item>
+          <a-descriptions-item label="更新时间">
+            {{ formatDate(currentPost.updated_at) }}
+          </a-descriptions-item>
+        </a-descriptions>
+
+        <section v-if="currentPost.poll" class="poll-detail">
+          <h4>投票:{{ currentPost.poll.question }}</h4>
+          <p>{{ currentPost.poll.status_text }} · {{ currentPost.poll.participant_count }} 人参与 · {{ currentPost.poll.selection_hint }}</p>
+          <p>结果展示:{{ ['始终可见', '投票后可见', '结束后可见'][currentPost.poll.result_visibility - 1] }};截止:{{ currentPost.poll.end_at ? formatDate(currentPost.poll.end_at * 1000) : '永久' }}</p>
+          <div v-for="option in currentPost.poll.options" :key="option.id" class="poll-option-detail">
+            <a-image v-if="option.image" :src="getImageUrl(option.image)" :width="64" />
+            <div class="poll-option-result">
+              <span>{{ option.content }} · {{ option.vote_count }} 票</span>
+              <a-progress :percent="option.percentage" />
+            </div>
+          </div>
+          <a-popconfirm v-if="currentPost.poll.status === 'active'" title="关闭后不能继续投票,确定关闭?" @confirm="closePoll">
+            <a-button danger :loading="closingPoll">关闭投票</a-button>
+          </a-popconfirm>
+        </section>
+        <!-- 媒体文件展示 -->
+        <div v-if="currentPost.media" class="media-section" style="margin-top: 16px;">
+          <h4>媒体文件</h4>
+          <div class="media-list">
+            <div v-for="(media, index) in parseMedia(currentPost.media)" :key="index" class="media-item">
+              <img v-if="isImage(media)" :src="getImageUrl(media)" alt="图片"
+                style="max-width: 200px; max-height: 200px;" />
+              <video v-else-if="isVideo(media)" :src="getImageUrl(media)" controls
+                style="max-width: 200px; max-height: 200px;"></video>
+              <a v-else :href="getImageUrl(media)" target="_blank">{{ media }}</a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, computed } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { DeleteOutlined } from '@ant-design/icons-vue'
+import DataList from '@/components/DataList.vue'
+import SearchForm from '@/components/SearchForm.vue'
+import { postApi } from '@/api/content'
+import { formatDate } from '@/utils/date'
+import { getImageUrl } from '@/utils/utils'
+
+// 响应式数据
+const dataListRef = ref()
+const selectedRowKeys = ref([])
+const detailModalVisible = ref(false)
+const currentPost = ref(null)
+const closingPoll = ref(false)
+
+async function closePoll() {
+  if (closingPoll.value || !currentPost.value?.poll) return
+  closingPoll.value = true
+  try {
+    const response = await postApi.closePoll({ poll_id: currentPost.value.poll.id })
+    currentPost.value.poll = response.result
+    message.success('投票已关闭')
+    dataListRef.value.refreshData()
+  } catch {
+    // 统一请求层负责展示权限与接口错误。
+  } finally {
+    closingPoll.value = false
+  }
+}
+const topicOptions = ref([])
+
+// 搜索表单配置
+const searchItems = ref([
+  {
+    label: '标题',
+    prop: 'title',
+    type: 'input',
+    placeholder: '请输入帖子标题'
+  },
+  {
+    label: '创建时间',
+    prop: 'date_range',
+    type: 'daterange',
+    placeholder: ['开始日期', '结束日期']
+  }
+])
+
+// 表格列配置
+const columns = [
+  {
+    title: '内容',
+    dataIndex: 'content',
+    width: 200,
+    slot: true
+  },
+  {
+    title: '发布者',
+    dataIndex: 'user',
+    width: 150,
+    slot: true
+  },
+  {
+    title: '圈子',
+    dataIndex: 'topic',
+    width: 200,
+    slot: true
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'created_at',
+    width: 180,
+    slot: true,
+    sorter: true
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 150,
+    type: 'action',
+    options: [
+      { title: '查看', key: 'view' },
+      { title: '删除', key: 'delete', type: 'delete' }
+    ]
+  }
+]
+
+// 行选择配置
+const rowSelection = {
+  selectedRowKeys: selectedRowKeys,
+  onChange: (keys) => {
+    selectedRowKeys.value = keys
+  }
+}
+
+// 计算属性
+const hasSelected = computed(() => selectedRowKeys.value.length > 0)
+
+// 方法
+const handleSearch = (params) => {
+  dataListRef.value.search(params)
+}
+
+const handleReset = () => {
+  dataListRef.value.reset()
+}
+
+const handleDelete = async (id) => {
+  try {
+    await postApi.deletePost({ id })
+    message.success('删除成功')
+    dataListRef.value.refreshData()
+  } catch (error) {
+    notifyError(error, '删除失败:' + error.message)
+  }
+}
+
+const handleBatchDelete = () => {
+  if (selectedRowKeys.value.length === 0) {
+    message.warning('请选择要删除的帖子')
+    return
+  }
+
+  Modal.confirm({
+    title: '确认删除',
+    content: `确定要删除选中的 ${selectedRowKeys.value.length} 个帖子吗?`,
+    onOk: async () => {
+      try {
+        await postApi.batchDeletePosts({ ids: selectedRowKeys.value })
+        message.success('批量删除成功')
+        selectedRowKeys.value = []
+        dataListRef.value.refreshData()
+      } catch (error) {
+        notifyError(error, '批量删除失败:' + error.message)
+      }
+    }
+  })
+}
+
+const viewDetail = async (record) => {
+  try {
+    const response = await postApi.getPostDetail({ id: record.id })
+
+    if (response && response.result) {
+      currentPost.value = response.result
+      detailModalVisible.value = true
+    } else {
+      message.error('获取帖子详情失败:响应数据为空')
+    }
+  } catch (error) {
+    notifyError(error, '获取帖子详情失败:' + error.message)
+  }
+}
+
+const parseMedia = (mediaStr) => {
+  if (!mediaStr || mediaStr === 'null' || mediaStr === 'undefined') return []
+
+  // 如果已经是数组,直接返回
+  if (Array.isArray(mediaStr)) {
+    return mediaStr.filter(Boolean)
+  }
+
+  // 尝试解析JSON
+  try {
+    const parsed = JSON.parse(mediaStr)
+    if (Array.isArray(parsed)) {
+      return parsed.filter(Boolean)
+    }
+    return []
+  } catch {
+    // 如果不是JSON,按逗号分割
+    if (typeof mediaStr === 'string') {
+      return mediaStr.split(',').filter(item => item && item.trim())
+    }
+    return []
+  }
+}
+
+const isImage = (url) => {
+  if (url && typeof url === 'object') return url.type === 'image'
+  return /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
+}
+
+const isVideo = (url) => {
+  if (url && typeof url === 'object') return url.type === 'video'
+  return /\.(mp4|avi|mov|wmv|flv|webm)$/i.test(url)
+}
+
+
+
+// 操作按钮点击事件
+const onAction = (action, record) => {
+  if (action.key === 'view') {
+    viewDetail(record)
+  }
+}
+</script>
+
+<style scoped>
+.poll-detail { margin-top: 20px; padding: 16px; border: 1px solid #eee; border-radius: 8px; }
+.poll-option-detail { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
+.poll-option-result { flex: 1; min-width: 0; }
+.posts-container {
+  padding: 24px;
+}
+
+.post-title {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.title-text {
+  font-weight: 500;
+  color: var(--meow-color-primary);
+  cursor: pointer;
+}
+
+.post-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  color: #666;
+}
+
+.meta-item {
+  background: #f5f5f5;
+  padding: 2px 6px;
+  border-radius: 4px;
+}
+
+.post-content-wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-width: 200px;
+}
+
+.post-text {
+  color: #666;
+  line-height: 1.4;
+  word-break: break-all;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.post-images {
+  margin-top: 4px;
+}
+
+.image-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 2px;
+  max-width: 180px;
+}
+
+.grid-image {
+  object-fit: cover;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: transform 0.2s;
+  display: block;
+}
+
+/* 确保a-image组件内的img元素不会拉伸变形 */
+.grid-image :deep(img) {
+  object-fit: cover !important;
+  width: 60px !important;
+  height: 60px !important;
+}
+
+.grid-image :deep(.ant-image) {
+  width: 60px !important;
+  height: 60px !important;
+}
+
+.grid-image :deep(.ant-image-img) {
+  object-fit: cover !important;
+}
+
+.grid-image:hover {
+  transform: scale(1.05);
+}
+
+.user-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.user-details {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.user-id {
+  font-size: 12px;
+  color: #999;
+}
+
+.topic-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.topic-details {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.topic-name {
+  font-weight: 500;
+}
+
+.topic-id {
+  font-size: 12px;
+  color: #999;
+}
+
+.post-content-detail {
+  max-height: 200px;
+  overflow-y: auto;
+  line-height: 1.6;
+  white-space: pre-wrap;
+}
+
+.media-section {
+  border-top: 1px solid #f0f0f0;
+  padding-top: 16px;
+}
+
+.media-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.media-item {
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.statistics-cards .ant-card {
+  text-align: center;
+}
+
+.table-operations {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 380 - 0
src/views/content/Reply.vue

@@ -0,0 +1,380 @@
+<template>
+    <div class="reply-container">
+        <!-- 搜索表单 -->
+        <div class="mb10">
+            <SearchForm :searchFields="searchItems" :maxFirstRowItems="3" @search="handleSearch" @reset="handleReset" />
+        </div>
+
+        <!-- 操作按钮 -->
+        <div class="mb10">
+            <div class="table-operations">
+                <a-button type="primary" danger :disabled="!hasSelected" @click="handleBatchDelete">
+                    批量删除
+                </a-button>
+                <span style="margin-left: 8px" v-if="hasSelected">
+                    已选择 {{ selectedRowKeys.length }} 项
+                </span>
+            </div>
+        </div>
+
+        <!-- 数据列表 -->
+        <div class="card-wrap">
+            <DataList ref="dataListRef" :api="(params) => commentApi.getListByPage(params)" :deleteApi="commentApi.deleteComment"
+                :columns="columns" :rowSelection="rowSelection" @action="onAction">
+                <template #content="{ record }">
+                    <div class="content-cell">
+                        {{ record.content.length > 50 ? record.content.substring(0, 50) + '...' : record.content }}
+                    </div>
+                </template>
+
+                <template #user="{ record }">
+                    <div v-if="record.user" class="flex items-center space-x-2" style="display: flex;align-items: center;">
+                        <a-avatar :size="32" :src="record.user.avatar ? getImageUrl(record.user.avatar) : undefined">
+                            {{ record.user.nickname ? record.user.nickname.charAt(0) : (record.user.username ?
+                                record.user.username.charAt(0) : 'U') }}
+                        </a-avatar>
+                        <div style="margin-left: 5px;">{{ record.user.nickname || record.user.username }}</div>
+                    </div>
+                    <div v-else class="text-gray-400">用户已删除</div>
+                </template>
+
+                <template #post="{ record }">
+                    <div v-if="record.post">
+                        <div class="max-w-xs truncate" :title="record.post.content">{{ record.post.content || '无内容' }}
+                        </div>
+                    </div>
+                    <div v-else class="text-gray-400">帖子已删除</div>
+                </template>
+
+                <template #status="{ record }">
+                    <a-tag :color="record.status === 1 ? 'green' : 'red'">
+                        {{ record.status === 1 ? '正常' : '禁用' }}
+                    </a-tag>
+                </template>
+
+                <template #created_at="{ record }">
+                    {{ formatDate(record.created_at * 1000) }}
+                </template>
+
+            </DataList>
+        </div>
+
+        <!-- 评论详情弹窗 -->
+        <a-modal v-model:open="detailModalVisible" title="评论详情" :footer="null" width="600px">
+            <div v-if="currentComment">
+                <a-descriptions :column="1" bordered>
+                    <a-descriptions-item label="评论内容">
+                        <div class="whitespace-pre-wrap">{{ currentComment.content }}</div>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="评论用户">
+                        <div v-if="currentComment.user">
+                            {{ currentComment.user.nickname || currentComment.user.username }}
+                            <span class="text-gray-500">(ID: {{ currentComment.user.id }})</span>
+                        </div>
+                        <span v-else class="text-gray-400">用户已删除</span>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="所属帖子">
+                        <div v-if="currentComment.post">
+                            <div class="whitespace-pre-wrap">{{ currentComment.post.content || '无内容' }}</div>
+                            <span class="text-gray-500">(ID: {{ currentComment.post.id }})</span>
+                        </div>
+                        <span v-else class="text-gray-400">帖子已删除</span>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="回复用户" v-if="currentComment.reply_user">
+                        <div v-if="currentComment.replyUser">
+                            {{ currentComment.replyUser.nickname || currentComment.replyUser.username }}
+                            <span class="text-gray-500">(ID: {{ currentComment.replyUser.id }})</span>
+                        </div>
+                        <span v-else class="text-gray-400">用户已删除</span>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="类型">
+                        <a-tag :color="currentComment.type === 1 ? 'blue' : 'orange'">
+                            {{ currentComment.type === 1 ? '评论' : '回复' }}
+                        </a-tag>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="状态">
+                        <a-tag :color="currentComment.status === 1 ? 'green' : 'red'">
+                            {{ currentComment.status === 1 ? '正常' : '禁用' }}
+                        </a-tag>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="点赞数">{{ currentComment.thumb_num || 0 }}</a-descriptions-item>
+                    <a-descriptions-item label="创建时间">{{ currentComment.created_at }}</a-descriptions-item>
+                    <a-descriptions-item label="更新时间">{{ currentComment.updated_at }}</a-descriptions-item>
+                </a-descriptions>
+            </div>
+        </a-modal>
+
+        <!-- 编辑评论弹窗 -->
+        <a-modal v-model:open="editModalVisible" title="编辑评论" @ok="handleSave"
+            @cancel="handleCancel" width="600px">
+            <a-form :model="editForm" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
+                <a-form-item label="评论内容" :rules="[{ required: true, message: '请输入评论内容' }]">
+                    <a-textarea v-model:value="editForm.content" :rows="4" placeholder="请输入评论内容" />
+                </a-form-item>
+                <a-form-item label="状态">
+                    <a-select v-model:value="editForm.status" placeholder="请选择状态">
+                        <a-select-option :value="1">正常</a-select-option>
+                        <a-select-option :value="0">禁用</a-select-option>
+                    </a-select>
+                </a-form-item>
+            </a-form>
+        </a-modal>
+    </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, reactive, computed } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import DataList from '@/components/DataList.vue'
+import SearchForm from '@/components/SearchForm.vue'
+import * as commentApi from '@/api/comment'
+import { getImageUrl } from '@/utils/utils'
+import { formatDate } from '@/utils/date'
+
+// 响应式数据
+const dataListRef = ref()
+const selectedRowKeys = ref([])
+const detailModalVisible = ref(false)
+const editModalVisible = ref(false)
+const currentComment = ref(null)
+
+// 搜索表单配置
+const searchItems = ref([
+    {
+        label: '评论内容',
+        prop: 'content',
+        type: 'input',
+        placeholder: '请输入评论内容'
+    },
+    {
+        label: '状态',
+        prop: 'status',
+        type: 'select',
+        placeholder: '请选择状态',
+        options: [
+            { label: '正常', value: 1 },
+            { label: '禁用', value: 0 }
+        ]
+    },
+    {
+        label: '创建时间',
+        prop: 'date_range',
+        type: 'daterange',
+        placeholder: ['开始日期', '结束日期']
+    }
+])
+
+// 编辑表单
+const editForm = reactive({
+    id: null,
+    content: '',
+    status: 1
+})
+
+// 计算属性
+const hasSelected = computed(() => selectedRowKeys.value.length > 0)
+
+// 行选择配置
+const rowSelection = {
+    selectedRowKeys: selectedRowKeys,
+    onChange: (keys) => {
+        selectedRowKeys.value = keys
+    }
+}
+
+// 表格列配置
+const columns = [
+    {
+        title: '评论内容',
+        dataIndex: 'content',
+        width: 200,
+        slot: true
+    },
+    {
+        title: '用户',
+        dataIndex: 'user',
+        width: 150,
+        slot: true
+    },
+    {
+        title: '帖子',
+        dataIndex: 'post',
+        width: 200,
+        slot: true
+    },
+    {
+        title: '状态',
+        dataIndex: 'status',
+        width: 100,
+        slot: true
+    },
+
+    {
+        title: '点赞数',
+        dataIndex: 'thumb_num',
+        width: 80
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at',
+        width: 180,
+        slot: true,
+        sorter: true
+    },
+    {
+        title: '操作',
+        key: 'action',
+        width: 200,
+        type: 'action',
+        options: [
+            { title: '查看', key: 'view' },
+            { title: '编辑', key: 'edit' },
+            { title: '删除', key: 'delete', type: 'delete' }
+        ]
+    }
+]
+
+// 搜索处理
+const handleSearch = (searchParams) => {
+    dataListRef.value.search(searchParams)
+}
+
+// 重置处理
+const handleReset = () => {
+    dataListRef.value.reset()
+}
+
+// DataList操作处理
+const onAction = (action, record) => {
+    if (action.key === 'view') {
+        showDetailModal(record)
+    } else if (action.key === 'edit') {
+        showEditModal(record)
+    }
+}
+
+// 显示详情弹窗
+const showDetailModal = (record) => {
+    currentComment.value = record
+    detailModalVisible.value = true
+}
+
+// 显示编辑弹窗
+const showEditModal = (record) => {
+    Object.assign(editForm, {
+        id: record.id,
+        content: record.content,
+        status: record.status
+    })
+    editModalVisible.value = true
+}
+
+// 保存
+const handleSave = async () => {
+    try {
+        await commentApi.updateComment(editForm)
+        message.success('更新评论成功')
+        editModalVisible.value = false
+        dataListRef.value.refreshData()
+    } catch (error) {
+        notifyError(error, '更新评论失败')
+    }
+}
+
+// 取消
+const handleCancel = () => {
+    editModalVisible.value = false
+}
+
+// 切换状态
+const toggleStatus = async (record) => {
+    try {
+        const newStatus = record.status === 1 ? 0 : 1
+        await commentApi.updateCommentStatus({ id: record.id, status: newStatus })
+        message.success(newStatus === 1 ? '启用成功' : '禁用成功')
+        dataListRef.value.refreshData()
+    } catch (error) {
+        notifyError(error, '状态更新失败')
+    }
+}
+
+// 删除单个
+const handleDelete = (record) => {
+    Modal.confirm({
+        title: '确认删除',
+        content: '确定要删除这条评论吗?',
+        onOk: async () => {
+            try {
+                await commentApi.deleteComment({ id: record.id })
+                message.success('删除成功')
+                dataListRef.value.refreshData()
+            } catch (error) {
+                notifyError(error, '删除失败')
+            }
+        }
+    })
+}
+
+// 批量删除
+const handleBatchDelete = () => {
+    Modal.confirm({
+        title: '确认批量删除',
+        content: `确定要删除选中的 ${selectedRowKeys.value.length} 条评论吗?`,
+        onOk: async () => {
+            try {
+                await commentApi.batchDeleteComments({ ids: selectedRowKeys.value })
+                message.success('批量删除成功')
+                selectedRowKeys.value = []
+                dataListRef.value.refreshData()
+            } catch (error) {
+                notifyError(error, '批量删除失败')
+            }
+        }
+    })
+}
+</script>
+
+<style scoped>
+.reply-container {
+    padding: 20px;
+}
+
+.search-form {
+    background: #fff;
+    padding: 20px;
+    margin-bottom: 20px;
+    border-radius: 6px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.operation-buttons {
+    margin-bottom: 20px;
+}
+
+.operation-buttons .ant-btn {
+    margin-right: 8px;
+}
+
+.content-cell {
+    max-width: 200px;
+    word-break: break-all;
+}
+
+.text-gray-500 {
+    color: #6b7280;
+}
+
+.text-gray-400 {
+    color: #9ca3af;
+}
+
+.text-xs {
+    font-size: 0.75rem;
+}
+
+.whitespace-pre-wrap {
+    white-space: pre-wrap;
+}
+</style>

+ 536 - 0
src/views/content/Topic.vue

@@ -0,0 +1,536 @@
+<template>
+  <div class="topics-container">
+    <!-- 搜索表单 -->
+    <div class="mb10">
+      <SearchForm :searchFields="searchItems" :maxFirstRowItems="3" @search="handleSearch" @reset="handleReset" />
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="mb10">
+      <div class="table-operations">
+        <a-button type="primary" danger :disabled="!hasSelected" @click="handleBatchDelete">
+          <DeleteOutlined /> 批量删除
+        </a-button>
+        <span style="margin-left: 8px;" v-if="hasSelected">
+          已选择 {{ selectedRowKeys.length }} 项
+        </span>
+      </div>
+    </div>
+
+    <!-- 数据列表 -->
+    <div class="card-wrap">
+      <DataList ref="dataListRef" :api="(params) => topicApi.getListByPage(params)" :deleteApi="topicApi.deleteTopic"
+        :columns="columns" :rowSelection="rowSelection" @action="onAction">
+        <!-- 自定义列插槽 -->
+        <template #name="{ record }">
+          <div class="topic-name">
+            <div class="name-text">{{ record.name }}</div>
+            <div class="topic-meta">
+              <span class="meta-item">成员: {{ record.user_num }}</span>
+              <span class="meta-item">帖子: {{ record.post_num }}</span>
+            </div>
+          </div>
+        </template>
+
+        <template #cover_image="{ record }">
+          <div class="topic-cover">
+            <a-image v-if="record.cover_image" :src="getImageUrl(record.cover_image)" :width="80" :height="60"
+              :preview="true" style="border-radius: 4px; object-fit: cover;" />
+            <div v-else class="no-cover">
+              <span>无封面</span>
+            </div>
+          </div>
+        </template>
+
+        <template #description="{ record }">
+          <div class="topic-description">
+            {{ record.description ? record.description.substring(0, 80) + (record.description.length > 80 ? '...' : '')
+              : '无描述' }}
+          </div>
+        </template>
+
+        <template #user="{ record }">
+          <div class="user-info">
+            <a-avatar :src="getImageUrl(record.user?.avatar)" size="small">
+              {{ record.user?.nickname?.charAt(0) }}
+            </a-avatar>
+            <div class="user-details">
+              <div>{{ record.user?.nickname || '未知用户' }}</div>
+            </div>
+          </div>
+        </template>
+
+        <template #status="{ record }">
+          <a-tag v-if="record.status === 0" color="red">关闭</a-tag>
+          <a-tag v-else-if="record.status === 1" color="green">正常</a-tag>
+          <a-tag v-else-if="record.status === 2" color="gold">推荐</a-tag>
+        </template>
+
+        <template #created_at="{ record }">
+          <div>{{ formatDate(record.created_at) }}</div>
+        </template>
+
+        <template #action="{ record }">
+          <a-space>
+            <a-button type="link" size="small" @click="viewDetail(record)">
+              查看详情
+            </a-button>
+            <a-button type="link" size="small" @click="editTopic(record)">
+              编辑
+            </a-button>
+            <a-dropdown>
+              <a-button type="link" size="small">
+                更多
+                <DownOutlined />
+              </a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item key="recommend" @click="handleStatusChange(record, 2)">
+                    <StarOutlined /> 设为推荐
+                  </a-menu-item>
+                  <a-menu-item key="normal" @click="handleStatusChange(record, 1)">
+                    <CheckOutlined /> 设为正常
+                  </a-menu-item>
+                  <a-menu-item key="close" @click="handleStatusChange(record, 0)">
+                    <StopOutlined /> 关闭圈子
+                  </a-menu-item>
+                  <a-menu-divider />
+                  <a-menu-item key="delete" danger>
+                    <a-popconfirm title="确定要删除这个圈子吗?" @confirm="handleDelete(record.id)">
+                      <DeleteOutlined /> 删除圈子
+                    </a-popconfirm>
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </a-space>
+        </template>
+      </DataList>
+    </div>
+
+    <!-- 圈子详情模态框 -->
+    <a-modal v-model:open="detailModalVisible" title="圈子详情" width="800px" :footer="null">
+      <div v-if="currentTopic" class="topic-detail">
+        <a-descriptions :column="2" bordered>
+          <a-descriptions-item label="圈子名称" :span="2">
+            {{ currentTopic.name }}
+          </a-descriptions-item>
+          <a-descriptions-item label="圈子描述" :span="2">
+            <div class="topic-description-detail">{{ currentTopic.description || '无描述' }}</div>
+          </a-descriptions-item>
+          <a-descriptions-item label="创建者">
+            {{ currentTopic.user?.nickname || '未知用户' }}
+          </a-descriptions-item>
+          <a-descriptions-item label="状态">
+            <a-tag v-if="currentTopic.status === 0" color="red">关闭</a-tag>
+            <a-tag v-else-if="currentTopic.status === 1" color="green">正常</a-tag>
+            <a-tag v-else-if="currentTopic.status === 2" color="gold">推荐</a-tag>
+          </a-descriptions-item>
+          <a-descriptions-item label="成员数量">
+            {{ currentTopic.user_num }}
+          </a-descriptions-item>
+          <a-descriptions-item label="帖子数量">
+            {{ currentTopic.post_num }}
+          </a-descriptions-item>
+          <a-descriptions-item label="创建时间">
+            {{ formatDate(currentTopic.created_at) }}
+          </a-descriptions-item>
+          <a-descriptions-item label="更新时间">
+            {{ formatDate(currentTopic.updated_at) }}
+          </a-descriptions-item>
+          <a-descriptions-item label="公告" :span="2">
+            <div class="topic-notice">{{ currentTopic.notice || '无公告' }}</div>
+          </a-descriptions-item>
+        </a-descriptions>
+
+        <!-- 封面图片展示 -->
+        <div v-if="currentTopic.cover_image" class="cover-section" style="margin-top: 16px;">
+          <h4>封面图片</h4>
+          <img :src="getImageUrl(currentTopic.cover_image)" alt="封面"
+            style="max-width: 300px; max-height: 200px; border-radius: 4px;" />
+        </div>
+
+        <!-- 背景图片展示 -->
+        <div v-if="currentTopic.bg_image" class="bg-section" style="margin-top: 16px;">
+          <h4>背景图片</h4>
+          <img :src="getImageUrl(currentTopic.bg_image)" alt="背景"
+            style="max-width: 300px; max-height: 200px; border-radius: 4px;" />
+        </div>
+      </div>
+    </a-modal>
+
+    <!-- 编辑圈子模态框 -->
+    <a-modal v-model:open="editModalVisible" title="编辑圈子" width="600px" @ok="handleSave" :confirmLoading="saveLoading">
+      <a-form ref="formRef" :model="editForm" :rules="editRules" layout="vertical">
+        <a-form-item label="圈子名称" name="name">
+          <a-input v-model:value="editForm.name" placeholder="请输入圈子名称" />
+        </a-form-item>
+        <a-form-item label="圈子描述" name="description">
+          <a-textarea v-model:value="editForm.description" placeholder="请输入圈子描述" :rows="3" />
+        </a-form-item>
+        <a-form-item label="状态" name="status">
+          <a-select v-model:value="editForm.status" placeholder="请选择状态">
+            <a-select-option :value="0">关闭</a-select-option>
+            <a-select-option :value="1">正常</a-select-option>
+            <a-select-option :value="2">推荐</a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="公告" name="notice">
+          <a-textarea v-model:value="editForm.notice" placeholder="请输入圈子公告" :rows="4" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, reactive, computed } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { DeleteOutlined, DownOutlined, StarOutlined, CheckOutlined, StopOutlined } from '@ant-design/icons-vue'
+import DataList from '@/components/DataList.vue'
+import SearchForm from '@/components/SearchForm.vue'
+import { topicApi } from '@/api/content'
+import { formatDate } from '@/utils/date'
+import { getImageUrl } from '@/utils/utils'
+
+// 响应式数据
+const dataListRef = ref()
+const formRef = ref()
+const selectedRowKeys = ref([])
+const detailModalVisible = ref(false)
+const editModalVisible = ref(false)
+const saveLoading = ref(false)
+const currentTopic = ref(null)
+const classOptions = ref([])
+
+// 编辑表单
+const editForm = reactive({
+  id: null,
+  name: '',
+  description: '',
+  status: 1,
+  notice: ''
+})
+
+// 表单验证规则
+const editRules = {
+  name: [
+    { required: true, message: '请输入圈子名称', trigger: 'blur' },
+    { max: 20, message: '圈子名称不能超过20个字符', trigger: 'blur' }
+  ],
+  description: [
+    { max: 255, message: '圈子描述不能超过255个字符', trigger: 'blur' }
+  ],
+  status: [
+    { required: true, message: '请选择状态', trigger: 'change' }
+  ],
+  notice: [
+    { max: 1000, message: '公告不能超过1000个字符', trigger: 'blur' }
+  ]
+}
+
+// 搜索表单配置
+const searchItems = ref([
+  {
+    label: '圈子名称',
+    prop: 'name',
+    type: 'input',
+    placeholder: '请输入圈子名称'
+  },
+  {
+    label: '状态',
+    prop: 'status',
+    type: 'select',
+    placeholder: '请选择状态',
+    options: [
+      { label: '关闭', value: 0 },
+      { label: '正常', value: 1 },
+      { label: '推荐', value: 2 }
+    ]
+  },
+  {
+    label: '创建时间',
+    prop: 'date_range',
+    type: 'daterange',
+    placeholder: ['开始日期', '结束日期']
+  }
+])
+
+// 表格列配置
+const columns = [
+  {
+    title: '封面',
+    dataIndex: 'cover_image',
+    width: 120,
+    slot: true
+  },
+  {
+    title: '圈子名称',
+    dataIndex: 'name',
+    width: 200,
+    slot: true
+  },
+  {
+    title: '描述',
+    dataIndex: 'description',
+    width: 250,
+    slot: true
+  },
+  {
+    title: '创建者',
+    dataIndex: 'user',
+    width: 150,
+    slot: true
+  },
+  {
+    title: '状态',
+    dataIndex: 'status',
+    width: 120,
+    slot: true
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'created_at',
+    width: 180,
+    slot: true,
+    sorter: true
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 200,
+    type: 'action',
+    options: [
+      { title: '查看', key: 'view' },
+      { title: '编辑', key: 'edit' },
+      { title: '删除', key: 'delete', type: 'delete' }
+    ]
+  }
+]
+
+// 行选择配置
+const rowSelection = {
+  selectedRowKeys: selectedRowKeys,
+  onChange: (keys) => {
+    selectedRowKeys.value = keys
+  }
+}
+
+// 计算属性
+const hasSelected = computed(() => selectedRowKeys.value.length > 0)
+
+// 方法
+const onAction = (item, record) => {
+  if (item.key === 'view') {
+    viewDetail(record)
+  } else if (item.key === 'edit') {
+    editTopic(record)
+  }
+}
+
+const handleSearch = (params) => {
+  dataListRef.value.search(params)
+}
+
+const handleReset = () => {
+  dataListRef.value.reset()
+}
+
+const handleDelete = async (id) => {
+  try {
+    await topicApi.deleteTopic({ id })
+    message.success('删除成功')
+    dataListRef.value.refreshData()
+  } catch (error) {
+    notifyError(error, '删除失败:' + error.message)
+  }
+}
+
+const handleBatchDelete = () => {
+  if (selectedRowKeys.value.length === 0) {
+    message.warning('请选择要删除的圈子')
+    return
+  }
+
+  Modal.confirm({
+    title: '确认删除',
+    content: `确定要删除选中的 ${selectedRowKeys.value.length} 个圈子吗?`,
+    onOk: async () => {
+      try {
+        await topicApi.batchDeleteTopics({ ids: selectedRowKeys.value })
+        message.success('批量删除成功')
+        selectedRowKeys.value = []
+        dataListRef.value.refreshData()
+      } catch (error) {
+        notifyError(error, '批量删除失败:' + error.message)
+      }
+    }
+  })
+}
+
+const handleStatusChange = async (record, status) => {
+  record.statusLoading = true
+  try {
+    await topicApi.updateTopicStatus({ id: record.id, status })
+    record.status = status
+    const statusText = { 0: '关闭', 1: '正常', 2: '推荐' }
+    message.success(`圈子状态已更新为:${statusText[status]}`)
+    dataListRef.value.refreshData()
+  } catch (error) {
+    notifyError(error, '状态更新失败:' + error.message)
+  } finally {
+    record.statusLoading = false
+  }
+}
+
+const viewDetail = async (record) => {
+  try {
+    const response = await topicApi.getTopicDetail({ id: record.id })
+
+    if (response && response.result) {
+      currentTopic.value = response.result
+      detailModalVisible.value = true
+    } else {
+      message.error('获取圈子详情失败:响应数据为空')
+    }
+  } catch (error) {
+    notifyError(error, '获取圈子详情失败:' + error.message)
+  }
+}
+
+const editTopic = (record) => {
+  editForm.id = record.id
+  editForm.name = record.name
+  editForm.description = record.description || ''
+  editForm.status = record.status
+  editForm.notice = record.notice || ''
+  editModalVisible.value = true
+}
+
+const handleSave = async () => {
+  try {
+    await formRef.value.validate()
+    saveLoading.value = true
+
+    await topicApi.saveTopic(editForm)
+    message.success('保存成功')
+    editModalVisible.value = false
+    dataListRef.value.refreshData()
+  } catch (error) {
+    if (error.message) {
+      notifyError(error, '保存失败:' + error.message)
+    }
+  } finally {
+    saveLoading.value = false
+  }
+}
+</script>
+
+<style scoped>
+.topics-container {
+  padding: 24px;
+}
+
+.topic-name {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.name-text {
+  font-weight: 500;
+  color: var(--meow-color-primary);
+  cursor: pointer;
+}
+
+.topic-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  color: #666;
+}
+
+.meta-item {
+  background: #f5f5f5;
+  padding: 2px 6px;
+  border-radius: 4px;
+}
+
+.topic-description {
+  color: #666;
+  line-height: 1.4;
+  max-width: 250px;
+  word-break: break-all;
+}
+
+.topic-cover {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.no-cover {
+  width: 80px;
+  height: 60px;
+  background: #f5f5f5;
+  border: 1px dashed #d9d9d9;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #999;
+  font-size: 12px;
+}
+
+.user-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.user-details {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.user-id {
+  font-size: 12px;
+  color: #999;
+}
+
+.topic-description-detail {
+  max-height: 150px;
+  overflow-y: auto;
+  line-height: 1.6;
+  white-space: pre-wrap;
+}
+
+.topic-notice {
+  max-height: 100px;
+  overflow-y: auto;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  background: #f5f5f5;
+  padding: 8px;
+  border-radius: 4px;
+}
+
+.cover-section,
+.bg-section {
+  border-top: 1px solid #f0f0f0;
+  padding-top: 16px;
+}
+
+.statistics-cards .ant-card {
+  text-align: center;
+}
+
+.table-operations {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 83 - 0
src/views/platform/Agreement.vue

@@ -0,0 +1,83 @@
+<template>
+  <a-card title="协议管理">
+    <template #extra><a-button :loading="loading" :disabled="saving" @click="loadConfig">重新加载</a-button></template>
+    <a-alert v-if="loadError" class="section-gap" type="error" show-icon message="协议加载失败,请重新加载;为保护已有内容,暂不能保存。" />
+    <a-spin :spinning="loading">
+      <a-form v-if="loaded" class="section-gap" layout="vertical" @finish="save">
+        <a-tabs v-model:active-key="activeType" :destroy-inactive-tab-pane="true">
+          <a-tab-pane v-for="item in types" :key="item.id" :tab="item.label">
+            <a-form-item label="协议标题"><a-input v-model:value="form[item.id + '_title']" :maxlength="100" /></a-form-item>
+            <a-form-item label="协议正文"><AgreementEditor v-model="form[item.id + '_content']" /></a-form-item>
+          </a-tab-pane>
+        </a-tabs>
+        <a-space wrap>
+          <a-button type="primary" html-type="submit" :loading="saving" :disabled="loading || !loaded">
+            保存
+          </a-button>
+          <a-button v-for="item in types" :key="item.id" :href="agreementUrl(item.id)" target="_blank" rel="noopener noreferrer" :disabled="!publishedOnServer">查看{{ item.label }}</a-button>
+        </a-space>
+      </a-form>
+    </a-spin>
+  </a-card>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { message } from 'ant-design-vue'
+import AgreementEditor from '@/components/AgreementEditor.vue'
+import { getPlatformConfig, saveConfig } from '@/api/config'
+
+const types = [{ id: 'user', label: '用户服务协议' }, { id: 'privacy', label: '隐私政策' }]
+const form = reactive({ user_title: '', user_content: '', privacy_title: '', privacy_content: '' })
+const loading = ref(false)
+const saving = ref(false)
+const loaded = ref(false)
+const loadError = ref(false)
+const publishedOnServer = ref(false)
+const activeType = ref('user')
+function applyConfig(value) {
+  // 表单只持有可编辑字段,发布状态由服务端在保存成功后维护。
+  for (const key of Object.keys(form)) form[key] = value[key]
+  publishedOnServer.value = Boolean(value.published)
+}
+function agreementUrl(id) {
+  return new URL('/agreement/' + id, new URL(import.meta.env.VITE_API_BASE_URL, window.location.origin)).href
+}
+async function loadConfig() {
+  if (loading.value || saving.value) return
+  loading.value = true
+  loaded.value = false
+  loadError.value = false
+  try {
+    const res = await getPlatformConfig({ key: 'agreements' })
+    if (res.code !== 200 || !res.result?.value) throw new Error('协议配置加载失败')
+    applyConfig(res.result.value)
+    loaded.value = true
+  } catch {
+    loadError.value = true
+  } finally { loading.value = false }
+}
+async function save() {
+  if (loading.value || saving.value || !loaded.value) return
+  for (const item of types) {
+    if (!String(form[item.id + '_title'] || '').trim()) {
+      activeType.value = item.id
+      message.warning('请填写' + item.label + '的标题')
+      return
+    }
+  }
+  saving.value = true
+  try {
+    const res = await saveConfig({ key: 'agreements', name: '协议管理', value: { ...form } })
+    if (res.code === 200) {
+      applyConfig(res.result.value)
+      message.success('协议已保存并生效')
+    }
+  } finally { saving.value = false }
+}
+onMounted(loadConfig)
+</script>
+
+<style scoped>
+.section-gap { margin-top: 24px; }
+</style>

+ 655 - 0
src/views/platform/Banner.vue

@@ -0,0 +1,655 @@
+<template>
+    <div class="banners-container">
+        <!-- 搜索表单 -->
+        <div class="mb10">
+            <SearchForm :searchFields="searchItems" :maxFirstRowItems="3" @search="handleSearch" @reset="handleReset" />
+        </div>
+
+        <!-- 操作按钮 -->
+        <div class="mb10">
+            <div class="table-operations">
+                <a-button type="primary" @click="addBanner">
+                    <PlusOutlined /> 新增广告
+                </a-button>
+                <a-button type="primary" danger :disabled="!hasSelected" @click="handleBatchDelete">
+                    <DeleteOutlined /> 批量删除
+                </a-button>
+                <span style="margin-left: 8px;" v-if="hasSelected">
+                    已选择 {{ selectedRowKeys.length }} 项
+                </span>
+            </div>
+        </div>
+
+        <!-- 数据列表 -->
+        <div class="card-wrap">
+            <DataList ref="dataListRef" :api="(params) => bannerApi.getListByPage(params)" :deleteApi="bannerApi.deleteBanner"
+                :columns="columns" :rowSelection="rowSelection" @action="onAction">
+                <!-- 自定义列插槽 -->
+                <template #title="{ record }">
+                    <div class="banner-title">
+                        <div class="title-text">{{ record.title }}</div>
+                    </div>
+                </template>
+
+                <template #cover_img="{ record }">
+                    <div class="banner-image">
+                        <a-image v-if="record.cover_img" :width="120" :height="60" :src="getImageUrl(record.cover_img)" :preview="true"
+                            style="border-radius: 4px; object-fit: cover;" />
+                        <span v-else class="no-image">暂无图片</span>
+                    </div>
+                </template>
+
+                <template #type="{ record }">
+                    <div class="banner-type">
+                        <a-tag :color="getTypeColor(record.type)">{{ getTypeName(record.type) }}</a-tag>
+                    </div>
+                </template>
+
+                <template #url="{ record }">
+                    <div class="banner-url">
+                        <a-tooltip :title="record.url">
+                            <span class="url-text">{{ record.url || '-' }}</span>
+                        </a-tooltip>
+                    </div>
+                </template>
+
+                <template #created_at="{ record }">
+                    <div>{{ formatDate(record.created_at) }}</div>
+                </template>
+
+                <template #updated_at="{ record }">
+                    <div>{{ formatDate(record.updated_at) }}</div>
+                </template>
+
+                <template #action="{ record }">
+                    <a-space>
+                        <a-button type="link" size="small" @click="editBanner(record)">
+                            编辑
+                        </a-button>
+                        <a-popconfirm title="确定要删除这个广告吗?" @confirm="handleDelete(record.id)">
+                            <a-button type="link" size="small" danger>
+                                删除
+                            </a-button>
+                        </a-popconfirm>
+                    </a-space>
+                </template>
+            </DataList>
+        </div>
+
+        <!-- 新增/编辑广告模态框 -->
+        <a-modal v-model:open="editModalVisible" :title="editForm.id ? '编辑广告' : '新增广告'" width="600px" @ok="handleSave"
+            :confirmLoading="saveLoading">
+            <a-form ref="formRef" :model="editForm" :rules="editRules" layout="vertical">
+                <a-form-item label="广告标题" name="title">
+                    <a-input v-model:value="editForm.title" placeholder="请输入广告标题" />
+                </a-form-item>
+
+                <a-form-item label="跳转类型" name="type">
+                    <a-select v-model:value="editForm.type" placeholder="请选择跳转类型" @change="handleTypeChange">
+                        <a-select-option :value="1">当前小程序页面</a-select-option>
+                        <a-select-option :value="2">外部小程序</a-select-option>
+                        <a-select-option :value="3">webview</a-select-option>
+                    </a-select>
+                </a-form-item>
+
+                <a-form-item label="跳转链接" name="url">
+                    <a-input v-model:value="editForm.url" :placeholder="getUrlPlaceholder()" />
+                </a-form-item>
+
+                <a-form-item v-if="editForm.type === 2" label="外部小程序AppId" name="app_id">
+                    <a-input v-model:value="editForm.app_id" placeholder="请输入外部小程序的AppId" />
+                </a-form-item>
+
+                <a-form-item label="广告封面" name="cover_file_id">
+                    <div class="custom-upload">
+                        <div class="upload-area" @click="triggerFileInput" :class="{ 'has-image': currentImageUrl }">
+                            <div v-if="currentImageUrl" class="image-preview">
+                                <img :src="getImageUrl(currentImageUrl)" alt="广告封面" />
+                                <div class="image-overlay">
+                                    <div class="overlay-actions">
+                                        <a-button type="text" size="small" @click.stop="handlePreviewImage">
+                                            <EyeOutlined />
+                                        </a-button>
+                                        <a-button type="text" size="small" @click.stop="removeImage">
+                                            <DeleteOutlined />
+                                        </a-button>
+                                    </div>
+                                </div>
+                            </div>
+                            <div v-else class="upload-placeholder">
+                                <div class="upload-icon">
+                                    <PlusOutlined />
+                                </div>
+                                <div class="upload-text">点击上传Banner图</div>
+                                <div class="upload-hint">建议尺寸: 2:1 比例</div>
+                            </div>
+                        </div>
+                        <input 
+                            ref="fileInputRef" 
+                            type="file" 
+                            accept="image/*" 
+                            style="display: none" 
+                            @change="handleFileSelect"
+                        />
+                        <div v-if="uploading" class="upload-progress">
+                            <a-progress :percent="uploadProgress" size="small" />
+                        </div>
+                    </div>
+                </a-form-item>
+            </a-form>
+        </a-modal>
+
+        <!-- 图片预览模态框 -->
+        <a-modal v-model:open="previewVisible" title="图片预览" :footer="null" centered>
+            <img :src="previewImageUrl" style="width: 100%; max-height: 500px; object-fit: contain;" />
+        </a-modal>
+    </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, reactive, computed, onMounted } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { DeleteOutlined, PlusOutlined, EyeOutlined } from '@ant-design/icons-vue'
+import DataList from '@/components/DataList.vue'
+import SearchForm from '@/components/SearchForm.vue'
+import { uploadBusinessFile } from '@/api/storage'
+import { bannerApi } from '@/api/banner'
+import { formatDate } from '@/utils/date'
+import { getImageUrl } from '@/utils/utils'
+
+// 响应式数据
+const dataListRef = ref()
+const formRef = ref()
+const selectedRowKeys = ref([])
+const editModalVisible = ref(false)
+const saveLoading = ref(false)
+const fileInputRef = ref()
+const currentImageUrl = ref('')
+const uploading = ref(false)
+const uploadProgress = ref(0)
+const previewVisible = ref(false)
+const previewImageUrl = ref('')
+
+// 编辑表单
+const editForm = reactive({
+    id: null,
+    title: '',
+    url: '',
+    type: 1,
+    app_id: '',
+    cover_file_id: ''
+})
+
+// 表单验证规则
+const editRules = {
+    title: [
+        { required: true, message: '请输入广告标题', trigger: 'blur' },
+        { max: 10, message: '广告标题不能超过10个字符', trigger: 'blur' }
+    ],
+    type: [
+        { required: true, message: '请选择跳转类型', trigger: 'change' }
+    ],
+    url: [
+        { required: true, message: '请输入跳转链接', trigger: 'blur' },
+        { max: 255, message: '跳转链接不能超过255个字符', trigger: 'blur' }
+    ],
+    app_id: [
+        { 
+            validator: (rule, value) => {
+                if (editForm.type === 2 && !value) {
+                    return Promise.reject('外部小程序需要填写AppId')
+                }
+                return Promise.resolve()
+            }, 
+            trigger: 'blur' 
+        }
+    ],
+    cover_file_id: [
+        { required: true, message: '请上传广告封面', trigger: 'change' }
+    ]
+}
+
+// 搜索表单配置
+const searchItems = ref([
+    {
+        label: '广告标题',
+        prop: 'title',
+        type: 'input',
+        placeholder: '请输入广告标题'
+    },
+    {
+        label: '创建时间',
+        prop: 'date_range',
+        type: 'daterange',
+        placeholder: ['开始日期', '结束日期']
+    }
+])
+
+// 表格列配置
+const columns = [
+    {
+        title: '广告标题',
+        dataIndex: 'title',
+        width: 150,
+        slot: true
+    },
+    {
+        title: '广告封面',
+        dataIndex: 'cover_img',
+        width: 120,
+        slot: true
+    },
+    {
+        title: '跳转类型',
+        dataIndex: 'type',
+        width: 120,
+        slot: true
+    },
+    {
+        title: '跳转链接',
+        dataIndex: 'url',
+        width: 200,
+        slot: true
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at',
+        width: 180,
+        slot: true,
+        sorter: true
+    },
+    {
+        title: '更新时间',
+        dataIndex: 'updated_at',
+        width: 180,
+        slot: true,
+        sorter: true
+    },
+    {
+        title: '操作',
+        key: 'action',
+        width: 150,
+        type: 'action',
+        options: [
+            { title: '编辑', key: 'edit' },
+            { title: '删除', key: 'delete', type: 'delete' }
+        ]
+    }
+]
+
+// 行选择配置
+const rowSelection = {
+    selectedRowKeys: selectedRowKeys,
+    onChange: (keys) => {
+        selectedRowKeys.value = keys
+    }
+}
+
+// 计算属性
+const hasSelected = computed(() => selectedRowKeys.value.length > 0)
+
+// 方法
+const onAction = (item, record) => {
+    if (item.key === 'edit') {
+        editBanner(record)
+    }
+}
+
+const handleSearch = (params) => {
+    dataListRef.value.search(params)
+}
+
+const handleReset = () => {
+    dataListRef.value.reset()
+}
+
+const addBanner = () => {
+    editForm.id = null
+    editForm.title = ''
+    editForm.url = ''
+    editForm.type = 1
+    editForm.app_id = ''
+    editForm.cover_file_id = ''
+    currentImageUrl.value = ''
+    editModalVisible.value = true
+}
+
+const editBanner = (record) => {
+    editForm.id = record.id
+    editForm.title = record.title
+    editForm.url = record.url
+    editForm.type = record.type || 1
+    editForm.app_id = record.app_id || ''
+    editForm.cover_file_id = record.cover_img
+    currentImageUrl.value = record.cover_img || ''
+    editModalVisible.value = true
+}
+
+// 处理跳转类型变化
+const handleTypeChange = (value) => {
+    // 当类型不是外部小程序时,清空appId
+    if (value !== 2) {
+        editForm.app_id = ''
+    }
+}
+
+// 获取URL输入框占位符
+const getUrlPlaceholder = () => {
+    switch (editForm.type) {
+        case 1:
+            return '请输入小程序页面路径,如:/pages/index/index'
+        case 2:
+            return '请输入外部小程序页面路径,如:/pages/index/index'
+        case 3:
+            return '请输入网页链接,如:https://www.example.com'
+        default:
+            return '请输入跳转链接'
+    }
+}
+
+// 获取跳转类型名称
+const getTypeName = (type) => {
+    switch (type) {
+        case 1:
+            return '当前小程序'
+        case 2:
+            return '外部小程序'
+        case 3:
+            return 'webview'
+        default:
+            return '未知类型'
+    }
+}
+
+// 获取跳转类型颜色
+const getTypeColor = (type) => {
+    switch (type) {
+        case 1:
+            return 'blue'
+        case 2:
+            return 'green'
+        case 3:
+            return 'orange'
+        default:
+            return 'default'
+    }
+}
+
+const handleDelete = async (id) => {
+    try {
+        await bannerApi.deleteBanner({ id })
+        message.success('删除成功')
+        dataListRef.value.refreshData()
+    } catch (error) {
+        notifyError(error, '删除失败:' + error.message)
+    }
+}
+
+const handleBatchDelete = () => {
+    if (selectedRowKeys.value.length === 0) {
+        message.warning('请选择要删除的广告')
+        return
+    }
+
+    Modal.confirm({
+        title: '确认删除',
+        content: `确定要删除选中的 ${selectedRowKeys.value.length} 个广告吗?`,
+        onOk: async () => {
+            try {
+                await bannerApi.batchDeleteBanners({ ids: selectedRowKeys.value })
+                message.success('批量删除成功')
+                selectedRowKeys.value = []
+                dataListRef.value.refreshData()
+            } catch (error) {
+                notifyError(error, '批量删除失败:' + error.message)
+            }
+        }
+    })
+}
+
+// 触发文件选择
+const triggerFileInput = () => {
+    fileInputRef.value?.click()
+}
+
+// 处理文件选择
+const handleFileSelect = async (event) => {
+    const file = event.target.files?.[0]
+    if (!file) return
+
+    // 验证文件类型
+    if (!file.type.startsWith('image/')) {
+        message.error('只能上传图片文件!')
+        return
+    }
+
+    // 验证文件大小(10MB)
+    const maxSize = 10 * 1024 * 1024
+    if (file.size > maxSize) {
+        message.error('图片大小不能超过10MB!')
+        return
+    }
+
+    try {
+        uploading.value = true
+        uploadProgress.value = 0
+
+        const response = await uploadBusinessFile(file, 'banner', (progress) => {
+            uploadProgress.value = Math.round(progress * 100)
+        })
+        const result = response.result
+
+        currentImageUrl.value = result
+        editForm.cover_file_id = result.file_id
+        message.success('图片上传成功')
+    } catch (error) {
+        console.error('图片上传失败:', error)
+        notifyError(error, '图片上传失败:' + error.message)
+    } finally {
+        uploading.value = false
+        uploadProgress.value = 0
+        // 清空文件输入框
+        event.target.value = ''
+    }
+}
+
+// 预览图片
+const handlePreviewImage = () => {
+    if (currentImageUrl.value) {
+        previewImageUrl.value = getImageUrl(currentImageUrl.value)
+        previewVisible.value = true
+    }
+}
+
+// 移除图片
+const removeImage = () => {
+    currentImageUrl.value = ''
+    editForm.cover_file_id = ''
+}
+
+const handleSave = async () => {
+    try {
+        // 验证是否上传了图片
+        if (!currentImageUrl.value) {
+            message.error('请先上传广告封面')
+            return
+        }
+
+        await formRef.value.validate()
+        saveLoading.value = true
+
+        if (editForm.id) {
+            await bannerApi.updateBanner(editForm)
+            message.success('更新成功')
+        } else {
+            await bannerApi.createBanner(editForm)
+            message.success('创建成功')
+        }
+
+        editModalVisible.value = false
+        dataListRef.value.refreshData()
+    } catch (error) {
+        if (error.message) {
+            notifyError(error, '保存失败:' + error.message)
+        }
+    } finally {
+        saveLoading.value = false
+    }
+}
+
+// 生命周期
+onMounted(() => {
+    // 初始化数据
+})
+</script>
+
+<style lang="less" scoped>
+.banners-container {
+    padding: 24px;
+}
+
+.banner-title {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.title-text {
+    font-weight: 500;
+    color: var(--meow-color-primary);
+    cursor: pointer;
+}
+
+.banner-image {
+    display: flex;
+    align-items: center;
+}
+
+.no-image {
+    color: #999;
+    font-size: 12px;
+}
+
+.banner-url {
+    max-width: 200px;
+}
+
+.url-text {
+    display: inline-block;
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    color: var(--meow-color-primary);
+    cursor: pointer;
+}
+
+.table-operations {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.image-preview {
+    margin-top: 8px;
+}
+
+.custom-upload {
+    .upload-area {
+        width: 240px;
+        height: 120px;
+        border: 1px dashed #d9d9d9;
+        border-radius: 6px;
+        cursor: pointer;
+        position: relative;
+        overflow: hidden;
+        transition: border-color 0.3s;
+
+        &:hover {
+            border-color: var(--meow-color-primary);
+        }
+
+        &.has-image {
+            border: none;
+        }
+    }
+
+    .upload-placeholder {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        height: 100%;
+        color: #999;
+
+        .upload-icon {
+            font-size: 24px;
+            margin-bottom: 8px;
+        }
+
+        .upload-text {
+            font-size: 12px;
+        }
+
+        .upload-hint {
+            font-size: 10px;
+            color: #999;
+            margin-top: 4px;
+        }
+    }
+
+    .image-preview {
+        width: 100%;
+        height: 100%;
+        position: relative;
+        margin: 0;
+
+        img {
+            width: 100%;
+            height: 100%;
+            object-fit: cover;
+            border-radius: 6px;
+        }
+
+        .image-overlay {
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background: rgba(0, 0, 0, 0.5);
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            opacity: 0;
+            transition: opacity 0.3s;
+            border-radius: 6px;
+
+            &:hover {
+                opacity: 1;
+            }
+        }
+
+        .overlay-actions {
+            display: flex;
+            gap: 8px;
+
+            .ant-btn {
+                color: white;
+                border: none;
+                background: transparent;
+
+                &:hover {
+                    background: rgba(255, 255, 255, 0.2);
+                }
+            }
+        }
+    }
+
+    .upload-progress {
+        margin-top: 8px;
+    }
+}
+</style>

+ 114 - 0
src/views/platform/User.vue

@@ -0,0 +1,114 @@
+<template>
+    <div class="card-wrap animate-on-enter">
+        <DataList ref="DataListRef" :api="(params) => getListByPage(params)" :columns="tableColumns">
+            <template #status="{ record }">
+                <a-switch 
+                    :checked="record.status == 1" 
+                    @change="(checked) => handleStatusChange(record, checked)"
+                    checked-children="正常" 
+                    un-checked-children="禁用"
+                />
+            </template>
+        </DataList>
+    </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref } from 'vue';
+import { message, Modal } from 'ant-design-vue';
+import { toggleUserStatus, getListByPage } from '@/api/user.js';
+import DataList from '@/components/DataList.vue';
+
+/**
+ * 页面全局变量
+ */
+const tableColumns = [
+    {
+        title: '昵称',
+        dataIndex: 'nickname'
+    },
+    {
+        title: '用户名',
+        dataIndex: 'username',
+    },   
+    {
+        title: '邮箱',
+        dataIndex: 'email',
+        customRender: ({ record }) => {
+            return record.email || '-';
+        }
+    },
+    {
+        title: '性别',
+        dataIndex: 'gender',
+        customRender: ({ record }) => {
+            const genderMap = { 0: '未知', 1: '男', 2: '女' };
+            return genderMap[record.gender] || '未知';
+        }
+    },
+    {
+        title: '登录方式',
+        dataIndex: 'login_type',
+        customRender: ({ record }) => {
+            const typeMap = {
+                'username': '用户名密码',
+                'wechat': '微信小程序',
+                'wechat_mp': '微信公众号'
+            };
+            return typeMap[record.login_type] || record.login_type;
+        }
+    },
+    {
+        title: '状态',
+        dataIndex: 'status',
+        slot: true,
+        align: 'center'
+    },
+    {
+        title: '最后登录时间',
+        dataIndex: 'last_login_time',
+        customRender: ({ record }) => {
+            return record.last_login_time || '-';
+        }
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at'
+    }
+];
+
+const DataListRef = ref();
+
+// 处理状态切换
+function handleStatusChange(record, checked) {
+    const newStatus = checked ? 1 : 0;
+    const actionText = checked ? '启用' : '禁用';
+    
+    Modal.confirm({
+        title: '确认操作',
+        content: `确定要${actionText}「${record.nickname}」吗?`,
+        onOk: async () => {
+            try {
+                const result = await toggleUserStatus({
+                    id: record.id,
+                    status: newStatus
+                });
+                
+                if (result.code === 200) {
+                    message.success(result.message);
+                    DataListRef.value.refreshData();
+                } else {
+                    message.error(result.message || '操作失败');
+                }
+            } catch (error) {
+                notifyError(error, '操作失败');
+            }
+        }
+    });
+}
+</script>
+
+<style lang="less" scoped>
+</style>

+ 507 - 0
src/views/storage/Config.vue

@@ -0,0 +1,507 @@
+<template>
+  <div class="storage-page">
+    <a-spin :spinning="loading">
+      <a-card class="storage-tabs-card" :bordered="true">
+        <a-tabs v-model:activeKey="selectedDriver" class="provider-tabs" :animated="false">
+          <a-tab-pane v-for="provider in configs" :key="provider.driver">
+            <template #tab>
+              <div class="provider-tab">
+                <img :src="providerLogo(provider.driver)" :alt="`${provider.driver_label} Logo`" />
+                <span>{{ provider.driver_label }}</span>
+              </div>
+            </template>
+
+            <div class="provider-panel">
+            <a-form layout="vertical" class="provider-form">
+              <section class="form-section">
+                <a-row :gutter="[24, 0]">
+                  <a-col :xs="24" :xl="12">
+                    <a-form-item label="存储桶/空间" required>
+                      <a-input
+                        v-model:value="provider.bucket"
+                        size="large"
+                        :disabled="providerHasFiles(provider)"
+                        :placeholder="bucketPlaceholder(provider.driver)"
+                      />
+                    </a-form-item>
+                  </a-col>
+                  <a-col v-if="provider.driver !== 'qiniu'" :xs="24" :xl="12">
+                    <a-form-item label="地域" required>
+                      <a-input
+                        v-model:value="provider.region"
+                        size="large"
+                        :disabled="providerHasFiles(provider)"
+                        :placeholder="regionPlaceholder(provider.driver)"
+                      />
+                    </a-form-item>
+                  </a-col>
+                  <a-col v-if="provider.driver !== 'qiniu'" :xs="24" :xl="12">
+                    <a-form-item label="自定义 Endpoint">
+                      <a-input
+                        v-model:value="provider.endpoint"
+                        size="large"
+                        :disabled="providerHasFiles(provider)"
+                        placeholder="可选,公有云默认自动推导"
+                      />
+                    </a-form-item>
+                  </a-col>
+                  <a-col :xs="24" :xl="12">
+                    <a-form-item label="访问域名" :required="provider.driver === 'qiniu'">
+                      <a-input v-model:value="provider.public_domain" size="large" placeholder="例如 https://static.example.com" />
+                    </a-form-item>
+                  </a-col>
+                  <a-col v-if="provider.driver === 'qiniu'" :xs="24" :xl="12">
+                    <a-form-item label="上传域名">
+                      <a-input
+                        v-model:value="provider.options.upload_endpoint"
+                        size="large"
+                        placeholder="可选,默认自动查询空间所属区域"
+                      />
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+              </section>
+
+              <section class="form-section">
+                <a-row :gutter="[24, 0]">
+                  <template v-if="provider.driver === 'aliyun'">
+                    <a-col :xs="24" :xl="12">
+                      <a-form-item label="AccessKey ID" required>
+                        <a-input
+                          v-model:value="provider.credentials.access_key_id"
+                          size="large"
+                          autocomplete="off"
+                          :placeholder="secretPlaceholder(provider, 'AccessKey ID')"
+                        />
+                      </a-form-item>
+                    </a-col>
+                    <a-col :xs="24" :xl="12">
+                      <a-form-item label="AccessKey Secret" required>
+                        <a-input-password
+                          v-model:value="provider.credentials.access_key_secret"
+                          size="large"
+                          autocomplete="new-password"
+                          :placeholder="secretPlaceholder(provider, 'AccessKey Secret')"
+                        />
+                      </a-form-item>
+                    </a-col>
+                  </template>
+                  <template v-else-if="provider.driver === 'tencent'">
+                    <a-col :xs="24" :xl="12">
+                      <a-form-item label="SecretId" required>
+                        <a-input
+                          v-model:value="provider.credentials.secret_id"
+                          size="large"
+                          autocomplete="off"
+                          :placeholder="secretPlaceholder(provider, 'SecretId')"
+                        />
+                      </a-form-item>
+                    </a-col>
+                    <a-col :xs="24" :xl="12">
+                      <a-form-item label="SecretKey" required>
+                        <a-input-password
+                          v-model:value="provider.credentials.secret_key"
+                          size="large"
+                          autocomplete="new-password"
+                          :placeholder="secretPlaceholder(provider, 'SecretKey')"
+                        />
+                      </a-form-item>
+                    </a-col>
+                  </template>
+                  <template v-else>
+                    <a-col :xs="24" :xl="12">
+                      <a-form-item label="AccessKey" required>
+                        <a-input
+                          v-model:value="provider.credentials.access_key"
+                          size="large"
+                          autocomplete="off"
+                          :placeholder="secretPlaceholder(provider, 'AccessKey')"
+                        />
+                      </a-form-item>
+                    </a-col>
+                    <a-col :xs="24" :xl="12">
+                      <a-form-item label="SecretKey" required>
+                        <a-input-password
+                          v-model:value="provider.credentials.secret_key"
+                          size="large"
+                          autocomplete="new-password"
+                          :placeholder="secretPlaceholder(provider, 'SecretKey')"
+                        />
+                      </a-form-item>
+                    </a-col>
+                  </template>
+                </a-row>
+              </section>
+
+              <section class="form-section form-section--last">
+                <div class="section-title">
+                  <SettingOutlined />
+                  <span>访问策略</span>
+                </div>
+                <a-row :gutter="[24, 0]">
+                  <a-col :xs="24" :xl="12">
+                    <a-form-item label="启用状态" required class="policy-item">
+                      <a-radio-group v-model:value="provider.status" button-style="solid">
+                        <a-radio-button :value="1">启用</a-radio-button>
+                        <a-radio-button :value="0">停用</a-radio-button>
+                      </a-radio-group>
+                      <div class="field-help">
+                        {{ provider.status === 1
+                          ? '启用后将作为新文件的写入目标,同时自动停用其他存储。'
+                          : '停用后不再接收新文件,历史文件仍可正常访问。' }}
+                      </div>
+                    </a-form-item>
+                  </a-col>
+                  <a-col :xs="24" :xl="12">
+                    <a-form-item label="文件读取权限" class="policy-item">
+                      <a-radio-group v-model:value="provider.access_mode" button-style="solid">
+                        <a-radio-button value="public">公开读</a-radio-button>
+                        <a-radio-button value="private">私有读</a-radio-button>
+                      </a-radio-group>
+                      <div class="field-help">
+                        {{ provider.access_mode === 'public' ? '文件可通过公开地址直接访问。' : '访问文件时由系统生成临时签名地址。' }}
+                      </div>
+                    </a-form-item>
+                  </a-col>
+                </a-row>
+              </section>
+            </a-form>
+
+            <div class="provider-actions">
+              <div class="action-note">
+                {{ actionNote(provider) }}
+              </div>
+              <a-space wrap>
+                <a-button
+                  v-if="provider.configured"
+                  :loading="testingDriver === provider.driver"
+                  @click="testProvider(provider)"
+                >
+                  测试连接
+                </a-button>
+                <a-button type="primary" :loading="provider.saving" @click="submitProvider(provider)">
+                  {{ primaryActionText(provider) }}
+                </a-button>
+              </a-space>
+            </div>
+            </div>
+          </a-tab-pane>
+        </a-tabs>
+      </a-card>
+    </a-spin>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from 'vue'
+import { message, Modal } from 'ant-design-vue'
+import { SettingOutlined } from '@ant-design/icons-vue'
+import { getStorageConfigs, saveStorageConfig, testStorageConfig } from '@/api/storage'
+import { notifyError } from '@/utils/request'
+import aliyunLogo from '@/assets/storage/alibabacloud-color.svg'
+import tencentLogo from '@/assets/storage/tencentcloud-color.svg'
+import qiniuLogo from '@/assets/storage/qiniu-color.svg'
+
+const configs = ref([])
+const loading = ref(false)
+const testingDriver = ref('')
+const selectedDriver = ref('')
+const providerLogos = { aliyun: aliyunLogo, tencent: tencentLogo, qiniu: qiniuLogo }
+const activeProvider = computed(() => configs.value.find(provider => provider.saved_status === 1))
+
+const emptyCredentials = driver => driver === 'aliyun'
+  ? { access_key_id: '', access_key_secret: '' }
+  : driver === 'tencent'
+    ? { secret_id: '', secret_key: '' }
+    : { access_key: '', secret_key: '' }
+
+function normalizeProvider(provider) {
+  const status = Number(provider.status || 0)
+  return {
+    ...provider,
+    status,
+    saved_status: status,
+    configured: Boolean(provider.configured),
+    credential_configured: Boolean(provider.credential_configured),
+    credentials: emptyCredentials(provider.driver),
+    options: { upload_endpoint: provider.options?.upload_endpoint || '' },
+    saving: false
+  }
+}
+
+async function loadConfigs() {
+  loading.value = true
+  try {
+    configs.value = ((await getStorageConfigs()).result || []).map(normalizeProvider)
+    selectedDriver.value = activeProvider.value?.driver || configs.value[0]?.driver || ''
+  } catch (error) {
+    notifyError(error, '加载配置失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function submitProvider(provider) {
+  const validationMessage = validateProvider(provider)
+  if (validationMessage) {
+    message.warning(validationMessage)
+    return
+  }
+  if (provider.status === provider.saved_status) {
+    saveProvider(provider)
+    return
+  }
+
+  if (provider.status === 0) {
+    Modal.confirm({
+      title: `确认停用${provider.driver_label}?`,
+      content: '停用后系统将暂时无法上传新文件,历史文件仍可按原存储配置正常访问。',
+      okText: '确认停用',
+      cancelText: '继续编辑',
+      okButtonProps: { danger: true },
+      onOk: () => saveProvider(provider)
+    })
+    return
+  }
+
+  const currentProvider = activeProvider.value
+  Modal.confirm({
+    title: `确认启用${provider.driver_label}?`,
+    content: currentProvider && currentProvider.driver !== provider.driver
+      ? `启用后将自动停用${currentProvider.driver_label},新文件改为写入${provider.driver_label};历史文件不会被移动或删除。`
+      : `启用后,新文件将写入${provider.driver_label};历史文件不会被移动或删除。`,
+    okText: currentProvider ? '确认切换' : '确认启用',
+    cancelText: '继续编辑',
+    onOk: () => saveProvider(provider)
+  })
+}
+
+async function saveProvider(provider) {
+  const previousStatus = provider.saved_status
+  provider.saving = true
+  try {
+    await saveStorageConfig(payloadOf(provider))
+    if (previousStatus !== provider.status) {
+      message.success(`${provider.driver_label}已保存并${provider.status === 1 ? '启用' : '停用'}`)
+    } else {
+      message.success(`${provider.driver_label}配置已保存`)
+    }
+    await loadConfigs()
+  } catch (error) {
+    notifyError(error, '保存失败')
+    await loadConfigs()
+  } finally {
+    provider.saving = false
+  }
+}
+
+async function testProvider(provider) {
+  testingDriver.value = provider.driver
+  try {
+    await testStorageConfig(provider.id)
+    message.success(`${provider.driver_label}连接成功`)
+  } catch (error) {
+    notifyError(error, '连接失败')
+  } finally {
+    testingDriver.value = ''
+  }
+}
+
+function payloadOf(provider) {
+  return {
+    id: provider.id,
+    driver: provider.driver,
+    bucket: provider.bucket,
+    region: provider.region,
+    endpoint: provider.endpoint,
+    public_domain: provider.public_domain,
+    access_mode: provider.access_mode,
+    status: Number(provider.status),
+    credentials: provider.credentials,
+    options: provider.options
+  }
+}
+
+function validateProvider(provider) {
+  if (!provider.bucket?.trim()) return '请输入存储桶或空间名称'
+  if (provider.driver !== 'qiniu' && !provider.region?.trim()) return '请输入地域'
+  if (provider.driver === 'qiniu' && !provider.public_domain?.trim()) return '请输入七牛云访问域名'
+  if (!provider.credential_configured) {
+    if (Object.values(provider.credentials).some(value => !String(value || '').trim())) return '请完整填写云存储访问密钥'
+  }
+  return ''
+}
+
+function primaryActionText(provider) {
+  if (provider.status === 0 && provider.saved_status === 1) return '保存并停用'
+  if (provider.status === 1 && provider.saved_status === 0) {
+    return activeProvider.value ? '保存并切换' : '保存并启用'
+  }
+  return '保存配置'
+}
+
+function actionNote(provider) {
+  if (provider.status === 0 && provider.saved_status === 1) return '停用后新文件上传将暂时不可用'
+  if (provider.status === 1 && provider.saved_status === 0 && activeProvider.value) {
+    return `保存后将自动停用${activeProvider.value.driver_label}`
+  }
+  return '保存前请确认存储参数填写正确'
+}
+
+function providerLogo(driver) {
+  return providerLogos[driver] || ''
+}
+
+function bucketPlaceholder(driver) {
+  return driver === 'tencent' ? '格式:BucketName-APPID' : '请输入存储桶或空间名称'
+}
+
+function regionPlaceholder(driver) {
+  return driver === 'aliyun' ? '例如 cn-hangzhou' : '例如 ap-guangzhou'
+}
+
+function secretPlaceholder(provider, label) {
+  return provider.credential_configured ? `留空则保留原 ${label}` : `请输入 ${label}`
+}
+
+function providerHasFiles(provider) {
+  return Boolean(provider.has_files)
+}
+
+onMounted(loadConfigs)
+</script>
+
+<style scoped>
+.storage-page {
+  padding-bottom: 24px;
+}
+
+.storage-tabs-card {
+  overflow: hidden;
+  border-radius: 12px;
+}
+
+.provider-tab {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+}
+
+.provider-tab img {
+  width: 22px;
+  height: 22px;
+  object-fit: contain;
+}
+
+.provider-panel {
+  min-height: 420px;
+}
+
+.provider-form {
+  padding-top: 2px;
+}
+
+.form-section {
+  padding: 22px 0 4px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.form-section--last {
+  border-bottom: 0;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 16px;
+  color: #262626;
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.section-title :deep(.anticon) {
+  color: #1677ff;
+}
+
+.section-title small {
+  margin-left: 4px;
+  color: #8c8c8c;
+  font-size: 12px;
+  font-weight: 400;
+}
+
+.provider-form :deep(.ant-form-item) {
+  margin-bottom: 18px;
+}
+
+.provider-form :deep(.ant-form-item-label > label) {
+  color: #434343;
+  font-weight: 500;
+}
+
+.policy-item {
+  margin-bottom: 14px !important;
+}
+
+.field-help {
+  margin-top: 9px;
+  color: #8c8c8c;
+  font-size: 12px;
+}
+
+.provider-actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 16px 24px;
+  border-top: 1px solid #f0f0f0;
+  background: #fafafa;
+}
+
+.action-note {
+  color: #8c8c8c;
+  font-size: 13px;
+}
+
+@media (max-width: 768px) {
+  .provider-actions {
+    align-items: stretch;
+    flex-direction: column;
+    padding: 14px 16px;
+  }
+
+  .provider-actions :deep(.ant-space) {
+    justify-content: flex-end;
+  }
+
+}
+
+@media (max-width: 576px) {
+  .action-note {
+    display: none;
+  }
+
+  .section-title {
+    flex-wrap: wrap;
+  }
+
+  .section-title small {
+    width: 100%;
+    margin-left: 24px;
+  }
+
+  .provider-actions :deep(.ant-space) {
+    width: 100%;
+  }
+
+  .provider-actions :deep(.ant-space-item:last-child) {
+    flex: 1;
+  }
+
+  .provider-actions :deep(.ant-space-item:last-child .ant-btn) {
+    width: 100%;
+  }
+}
+</style>

+ 250 - 0
src/views/storage/Files.vue

@@ -0,0 +1,250 @@
+<template>
+  <div class="file-page">
+    <div class="card-wrap toolbar">
+      <a-space wrap>
+        <a-select v-model:value="storageId" class="storage-select" placeholder="请选择云存储" @change="changeStorage">
+          <a-select-option v-for="item in storages" :key="item.id" :value="item.id">
+            {{ item.name }}({{ item.driver_label }})
+          </a-select-option>
+        </a-select>
+        <a-button :icon="h(ReloadOutlined)" :loading="loading" :disabled="!storageId" @click="reload">刷新</a-button>
+        <a-button :icon="h(FolderAddOutlined)" :disabled="!storageId" @click="directoryOpen = true">新建目录</a-button>
+        <a-upload multiple :show-upload-list="false" :before-upload="beforeUpload" :disabled="!storageId">
+          <a-button type="primary" :icon="h(UploadOutlined)" :loading="uploading">上传文件</a-button>
+        </a-upload>
+        <a-select v-model:value="category" class="category-select" placeholder="全部类型" allow-clear @change="clearSelection">
+          <a-select-option v-for="item in categoryOptions" :key="item.value" :value="item.value">{{ item.label }}</a-select-option>
+        </a-select>
+        <span v-if="selectedRowKeys.length" class="selection-summary">
+          已选择 {{ selectedRowKeys.length }} 项
+          <a-button type="link" size="small" @click="clearSelection">取消选择</a-button>
+        </span>
+      </a-space>
+    </div>
+
+    <div class="card-wrap">
+      <a-alert v-if="!storages.length && !storageLoading" type="warning" show-icon message="暂无已启用的云存储,请先到“存储配置”中开启并完成配置。" />
+      <template v-else>
+        <a-breadcrumb class="breadcrumb">
+          <a-breadcrumb-item><a @click="enterDirectory('')">根目录</a></a-breadcrumb-item>
+          <a-breadcrumb-item v-for="part in breadcrumbs" :key="part.key"><a @click="enterDirectory(part.key)">{{ part.name }}</a></a-breadcrumb-item>
+        </a-breadcrumb>
+        <a-table
+          row-key="key"
+          :data-source="rows"
+          :columns="columns"
+          :loading="loading || storageLoading"
+          :pagination="false"
+          :row-selection="rowSelection"
+        >
+          <template #bodyCell="{ column, record }">
+            <template v-if="column.key === 'name'">
+              <a-space>
+                <component
+                  :is="record.kind === 'directory' ? FolderFilled : iconFor(record.category)"
+                  :class="['file-icon', { 'file-icon--directory': record.kind === 'directory' }]"
+                />
+                <a v-if="record.kind === 'directory'" class="directory-link" @click="enterDirectory(record.key)">
+                  {{ record.name }}
+                </a>
+                <span v-else>{{ record.name }}</span>
+              </a-space>
+            </template>
+            <template v-else-if="column.key === 'preview'">
+              <a-image
+                v-if="record.kind === 'file' && record.category === 'image'"
+                class="image-thumbnail"
+                :width="52"
+                :height="52"
+                :src="record.url"
+                :preview="{ mask: '预览' }"
+              />
+              <span v-else>-</span>
+            </template>
+            <template v-else-if="column.key === 'size'">{{ record.kind === 'directory' ? '-' : formatSize(record.size) }}</template>
+            <template v-else-if="column.key === 'action'">
+              <a-space v-if="record.kind === 'file'">
+                <a-button type="link" size="small" @click="preview(record)">打开</a-button>
+                <a-button type="link" size="small" @click="openRename(record)">重命名</a-button>
+                <a-popconfirm title="确认删除该文件?此操作会同步删除云端对象。" @confirm="remove(record)">
+                  <a-button type="link" danger size="small">删除</a-button>
+                </a-popconfirm>
+              </a-space>
+              <a-space v-else>
+                <a-popconfirm title="只能删除空目录,确认继续?" @confirm="remove(record)">
+                  <a-button type="link" danger size="small">删除</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </template>
+        </a-table>
+        <div class="pager">
+          <a-space>
+            <a-button :disabled="cursorStack.length === 0 || loading" @click="previousPage">上一页</a-button>
+            <a-button :disabled="!nextCursor || loading" @click="nextPage">下一页</a-button>
+          </a-space>
+        </div>
+      </template>
+    </div>
+
+    <a-modal v-model:open="directoryOpen" title="新建目录" :confirm-loading="mutating" @ok="createDirectory">
+      <a-input v-model:value="directoryName" placeholder="请输入目录名称" @press-enter="createDirectory" />
+    </a-modal>
+    <a-modal v-model:open="renameOpen" title="重命名文件" :confirm-loading="mutating" @ok="renameFile">
+      <a-input v-model:value="newName" placeholder="请输入新文件名(请保留扩展名)" @press-enter="renameFile" />
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { computed, h, onMounted, ref } from 'vue'
+import { message } from 'ant-design-vue'
+import { ReloadOutlined, FolderAddOutlined, UploadOutlined, FolderFilled, FileImageOutlined, VideoCameraOutlined, CustomerServiceOutlined, FilePdfOutlined, FileTextOutlined, FileZipOutlined, FileUnknownOutlined } from '@ant-design/icons-vue'
+import { confirmCloudUpload, createCloudDirectory, deleteCloudFile, getCloudFiles, getEnabledStorages, prepareCloudUpload, renameCloudFile } from '@/api/storage'
+import { notifyError } from '@/utils/request'
+
+const columns = [
+  { title: '名称', key: 'name' }, { title: '预览', key: 'preview', width: 100 },
+  { title: '大小', key: 'size', width: 120 },
+  { title: '最后修改', dataIndex: 'last_modified', width: 210 }, { title: '操作', key: 'action', width: 240 }
+]
+const categoryOptions = [
+  { value: 'image', label: '图片' }, { value: 'video', label: '视频' }, { value: 'audio', label: '音频' },
+  { value: 'pdf', label: 'PDF' }, { value: 'document', label: '文档' }, { value: 'archive', label: '压缩包' }, { value: 'other', label: '其他' }
+]
+const storages = ref([])
+const storageId = ref('')
+const prefix = ref('')
+const directories = ref([])
+const files = ref([])
+const category = ref()
+const loading = ref(false)
+const storageLoading = ref(false)
+const uploading = ref(false)
+const mutating = ref(false)
+const nextCursor = ref(null)
+const currentCursor = ref(null)
+const cursorStack = ref([])
+const directoryOpen = ref(false)
+const directoryName = ref('')
+const renameOpen = ref(false)
+const renameTarget = ref(null)
+const newName = ref('')
+const selectedRowKeys = ref([])
+const rows = computed(() => [
+  ...directories.value.map(item => ({ ...item, kind: 'directory' })),
+  ...files.value.filter(item => !category.value || item.category === category.value).map(item => ({ ...item, kind: 'file' }))
+])
+const rowSelection = computed(() => ({
+  selectedRowKeys: selectedRowKeys.value,
+  columnWidth: 48,
+  onChange: keys => { selectedRowKeys.value = keys }
+}))
+const breadcrumbs = computed(() => {
+  let path = ''
+  return prefix.value.split('/').filter(Boolean).map(name => { path += `${name}/`; return { name, key: path } })
+})
+
+async function loadStorages() {
+  storageLoading.value = true
+  try {
+    storages.value = (await getEnabledStorages()).result || []
+    if (!storageId.value && storages.value.length) {
+      storageId.value = storages.value[0].id
+      await loadFiles()
+    }
+  } catch (e) { notifyError(e, '加载云存储失败') } finally { storageLoading.value = false }
+}
+async function loadFiles(cursor = currentCursor.value) {
+  if (!storageId.value) return
+  clearSelection()
+  loading.value = true
+  try {
+    const result = (await getCloudFiles({ storage_id: storageId.value, prefix: prefix.value, cursor, limit: 100 })).result
+    directories.value = result?.directories || []
+    files.value = result?.files || []
+    nextCursor.value = result?.next_cursor || null
+  } catch (e) { notifyError(e, '读取云端文件失败') } finally { loading.value = false }
+}
+function resetPaging() { currentCursor.value = null; nextCursor.value = null; cursorStack.value = [] }
+function clearSelection() { selectedRowKeys.value = [] }
+async function changeStorage() { prefix.value = ''; category.value = undefined; resetPaging(); await loadFiles() }
+async function enterDirectory(key) { prefix.value = key; category.value = undefined; resetPaging(); await loadFiles() }
+async function reload() { resetPaging(); await loadFiles() }
+async function nextPage() { if (!nextCursor.value) return; cursorStack.value.push(currentCursor.value); currentCursor.value = nextCursor.value; await loadFiles() }
+async function previousPage() { if (!cursorStack.value.length) return; currentCursor.value = cursorStack.value.pop() ?? null; await loadFiles() }
+
+async function createDirectory() {
+  if (!directoryName.value.trim()) return message.warning('请输入目录名称')
+  mutating.value = true
+  try { await createCloudDirectory({ storage_id: storageId.value, prefix: prefix.value, name: directoryName.value }); message.success('目录创建成功'); directoryOpen.value = false; directoryName.value = ''; await reload() }
+  catch (e) { notifyError(e, '创建目录失败') } finally { mutating.value = false }
+}
+function beforeUpload(file) {
+  if (file.size > 5 * 1024 * 1024 * 1024) {
+    message.error(`${file.name} 超过 5 GiB,当前直传模式暂不支持`)
+    return false
+  }
+  uploadOne(file)
+  return false
+}
+async function uploadOne(file) {
+  uploading.value = true
+  try {
+    const prepared = (await prepareCloudUpload({ storage_id: storageId.value, prefix: prefix.value, name: file.name, size: file.size, content_type: file.type || 'application/octet-stream' })).result
+    if (prepared.already_uploaded) {
+      message.success(`${file.name} 已确认上传`)
+      await reload()
+      return
+    }
+    const grant = prepared.upload
+    let response
+    if (grant.method === 'PUT') {
+      response = await fetch(grant.url, { method: 'PUT', headers: grant.headers || {}, body: file })
+    } else {
+      const body = new FormData()
+      Object.entries(grant.fields || {}).forEach(([key, value]) => body.append(key, value))
+      body.append('file', file)
+      response = await fetch(grant.url, { method: 'POST', body })
+    }
+    if (!response.ok) throw new Error(`云端上传失败(HTTP ${response.status})`)
+    await confirmCloudUpload(prepared.file_id)
+    message.success(`${file.name} 上传成功`)
+    await reload()
+  } catch (e) { notifyError(e, `${file.name} 上传失败`) } finally { uploading.value = false }
+}
+function openRename(record) { renameTarget.value = record; newName.value = record.name; renameOpen.value = true }
+async function renameFile() {
+  if (!newName.value.trim()) return message.warning('请输入新文件名')
+  mutating.value = true
+  try { await renameCloudFile({ storage_id: storageId.value, key: renameTarget.value.key, new_name: newName.value }); message.success('重命名成功'); renameOpen.value = false; await reload() }
+  catch (e) { notifyError(e, '重命名失败') } finally { mutating.value = false }
+}
+async function remove(record) {
+  mutating.value = true
+  try { await deleteCloudFile({ storage_id: storageId.value, key: record.key }); message.success('删除成功'); await reload() }
+  catch (e) { notifyError(e, '删除失败') } finally { mutating.value = false }
+}
+function preview(record) { window.open(record.url, '_blank', 'noopener,noreferrer') }
+function formatSize(value) { const size = Number(value || 0); if (size < 1024) return `${size} B`; if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`; if (size < 1073741824) return `${(size / 1048576).toFixed(1)} MB`; return `${(size / 1073741824).toFixed(2)} GB` }
+function iconFor(value) { return ({ image: FileImageOutlined, video: VideoCameraOutlined, audio: CustomerServiceOutlined, pdf: FilePdfOutlined, document: FileTextOutlined, archive: FileZipOutlined })[value] || FileUnknownOutlined }
+onMounted(loadStorages)
+</script>
+
+<style scoped>
+.file-page { display: grid; gap: 16px; }
+.card-wrap { padding: 20px; background: #fff; border-radius: 8px; }
+.toolbar { padding: 16px 20px; }
+.storage-select { width: 280px; }
+.category-select { width: 130px; }
+.selection-summary { color: #595959; white-space: nowrap; }
+.breadcrumb { margin-bottom: 18px; }
+.breadcrumb a { cursor: pointer; }
+.file-icon { color: #1677ff; font-size: 18px; }
+.file-icon--directory { color: #f7c948; font-size: 21px; }
+.directory-link { color: #262626; }
+.directory-link:hover { color: #1677ff; }
+.image-thumbnail { overflow: hidden; border-radius: 4px; }
+.image-thumbnail :deep(.ant-image-img) { object-fit: cover; }
+.pager { display: flex; justify-content: flex-end; margin-top: 16px; }
+</style>

+ 676 - 0
src/views/system/Dict.vue

@@ -0,0 +1,676 @@
+<template>
+    <div class="dict-page">
+        <div class="card-wrap mb10 animate-on-enter">
+            <a-space wrap>
+                <a-button type="primary" :icon="h(PlusCircleOutlined)" @click="onAddDictType">新增字典类型</a-button>
+            </a-space>
+        </div>
+        <div class="card-wrap animate-on-enter animate-delay-1">
+            <DataList ref="dictTypeListRef" :api="getDictTypes" :deleteApi="deleteDictType" :columns="dictTypeColumns"
+                @action="handleDictTypeAction"></DataList>
+        </div>
+
+
+
+        <!-- 新增/编辑字典类型弹窗 -->
+        <a-modal v-model:open="openDictTypeModal" :title="dictTypeFormState.id ? '编辑字典类型' : '新增字典类型'"
+            :maskClosable="false" :footer="null">
+            <a-form ref="dictTypeFormRef" :model="dictTypeFormState" :label-col="labelCol" :rules="dictTypeRules"
+                @finish="onDictTypeSubmit">
+                <a-form-item label="字典名称" name="dict_name">
+                    <a-input v-model:value="dictTypeFormState.dict_name" placeholder="请填写字典名称" />
+                </a-form-item>
+                <a-form-item label="字典类型" name="dict_type">
+                    <a-input v-model:value="dictTypeFormState.dict_type" placeholder="请填写字典类型(英文字母、数字、下划线)"
+                        :disabled="!!dictTypeFormState.id" />
+                </a-form-item>
+                <a-form-item label="状态" name="status">
+                    <a-radio-group v-model:value="dictTypeFormState.status">
+                        <a-radio :value="1">正常</a-radio>
+                        <a-radio :value="0">停用</a-radio>
+                    </a-radio-group>
+                </a-form-item>
+                <a-form-item label="备注" name="remark">
+                    <a-textarea v-model:value="dictTypeFormState.remark" placeholder="请填写备注" :rows="3" />
+                </a-form-item>
+                <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+                    <a-button type="primary" html-type="submit" :loading="dictTypeSubmitLoading">保存</a-button>
+                    <a-button style="margin-left: 10px" @click="openDictTypeModal = false">取消</a-button>
+                </a-form-item>
+            </a-form>
+        </a-modal>
+
+        <!-- 字典数据管理弹窗 -->
+        <a-modal v-model:open="openDictDataModal" :title="`字典数据管理 - ${currentDictType?.dict_name || ''}`"
+            width="1200px" :footer="null">
+            <div class="dict-data-modal-content">
+                <div class="modal-header">
+                    <a-button type="primary" :icon="h(PlusCircleOutlined)" @click="onAddDictData">
+                        新增字典数据
+                    </a-button>
+                </div>
+                
+                <a-table 
+                    :columns="dictDataTableColumns" 
+                    :data-source="dictDataList" 
+                    :loading="dictDataLoading"
+                    :pagination="showPagination ? dictDataPagination : false"
+                    row-key="id"
+                    size="middle">
+                    
+                    <!-- 字典标签列 -->
+                    <template #bodyCell="{ column, record, index }">
+                        <template v-if="column.dataIndex === 'dict_label'">
+                            <a-input 
+                                v-if="editingKey === record.id" 
+                                v-model:value="record.dict_label" 
+                                @pressEnter="saveEdit(record)"
+                                @blur="saveEdit(record)"
+                                style="margin: -5px 0" />
+                            <span v-else>{{ record.dict_label }}</span>
+                        </template>
+                        
+                        <!-- 字典键值列 -->
+                        <template v-else-if="column.dataIndex === 'dict_value'">
+                            <a-input 
+                                v-if="editingKey === record.id" 
+                                v-model:value="record.dict_value" 
+                                @pressEnter="saveEdit(record)"
+                                @blur="saveEdit(record)"
+                                style="margin: -5px 0" />
+                            <span v-else>{{ record.dict_value }}</span>
+                        </template>
+                        
+                        <!-- 显示排序列 -->
+                        <template v-else-if="column.dataIndex === 'dict_sort'">
+                            <a-input-number 
+                                v-if="editingKey === record.id" 
+                                v-model:value="record.dict_sort" 
+                                :min="0"
+                                @pressEnter="saveEdit(record)"
+                                @blur="saveEdit(record)"
+                                style="margin: -5px 0; width: 100%" />
+                            <span v-else>{{ record.dict_sort }}</span>
+                        </template>
+                        
+                        <!-- 状态列 -->
+                        <template v-else-if="column.dataIndex === 'status'">
+                            <a-select 
+                                v-if="editingKey === record.id" 
+                                v-model:value="record.status" 
+                                @change="saveEdit(record)"
+                                style="margin: -5px 0; width: 100%">
+                                <a-select-option :value="1">正常</a-select-option>
+                                <a-select-option :value="0">停用</a-select-option>
+                            </a-select>
+                            <a-tag v-else :color="record.status === 1 ? 'green' : 'red'">
+                                {{ record.status === 1 ? '正常' : '停用' }}
+                            </a-tag>
+                        </template>
+                        
+                        <!-- 是否默认列 -->
+                        <template v-else-if="column.dataIndex === 'is_default'">
+                            <a-select 
+                                v-if="editingKey === record.id" 
+                                v-model:value="record.is_default" 
+                                @change="saveEdit(record)"
+                                style="margin: -5px 0; width: 100%">
+                                <a-select-option :value="1">是</a-select-option>
+                                <a-select-option :value="0">否</a-select-option>
+                            </a-select>
+                            <a-tag v-else :color="record.is_default === 1 ? 'blue' : 'default'">
+                                {{ record.is_default === 1 ? '是' : '否' }}
+                            </a-tag>
+                        </template>
+                        
+                        <!-- 备注列 -->
+                        <template v-else-if="column.dataIndex === 'remark'">
+                            <a-input 
+                                v-if="editingKey === record.id" 
+                                v-model:value="record.remark" 
+                                @pressEnter="saveEdit(record)"
+                                @blur="saveEdit(record)"
+                                style="margin: -5px 0" />
+                            <span v-else>{{ record.remark || '-' }}</span>
+                        </template>
+                        
+                        <!-- 操作列 -->
+                        <template v-else-if="column.key === 'action'">
+                            <span v-if="editingKey === record.id">
+                                <a @click="saveEdit(record)" style="margin-right: 8px" :class="{ 'ant-btn-loading': saveEditLoading }">保存</a>
+                                <a @click="cancelEdit" :disabled="saveEditLoading">取消</a>
+                            </span>
+                            <span v-else>
+                                <a @click="startEdit(record)" style="margin-right: 8px">编辑</a>
+                                <a-popconfirm title="确定删除吗?" @confirm="deleteDictDataItem(record.id)">
+                                    <a style="color: #ff4d4f">删除</a>
+                                </a-popconfirm>
+                            </span>
+                        </template>
+                    </template>
+                </a-table>
+            </div>
+        </a-modal>
+
+        <!-- 新增/编辑字典数据表单弹窗 -->
+        <a-modal v-model:open="openDictDataFormModal" :title="dictDataFormState.id ? '编辑字典数据' : '新增字典数据'"
+            :maskClosable="false" :footer="null">
+            <a-form ref="dictDataFormRef" :model="dictDataFormState" :label-col="labelCol" :rules="dictDataRules"
+                @finish="onDictDataSubmit">
+                <a-form-item label="字典类型" name="dict_type">
+                    <a-input v-model:value="dictDataFormState.dict_type" disabled />
+                </a-form-item>
+                <a-form-item label="数据标签" name="dict_label">
+                    <a-input v-model:value="dictDataFormState.dict_label" placeholder="请填写数据标签" />
+                </a-form-item>
+                <a-form-item label="数据键值" name="dict_value">
+                    <a-input v-model:value="dictDataFormState.dict_value" placeholder="请填写数据键值" />
+                </a-form-item>
+                <a-form-item label="显示排序" name="dict_sort">
+                    <a-input-number v-model:value="dictDataFormState.dict_sort" placeholder="请填写显示排序" :min="0"
+                        style="width: 100%" />
+                </a-form-item>
+                <a-form-item label="样式属性" name="css_class">
+                    <a-input v-model:value="dictDataFormState.css_class" placeholder="请填写样式属性" />
+                </a-form-item>
+                <a-form-item label="表格样式" name="list_class">
+                    <a-select v-model:value="dictDataFormState.list_class" placeholder="请选择表格样式">
+                        <a-select-option value="default">默认</a-select-option>
+                        <a-select-option value="primary">主要</a-select-option>
+                        <a-select-option value="success">成功</a-select-option>
+                        <a-select-option value="info">信息</a-select-option>
+                        <a-select-option value="warning">警告</a-select-option>
+                        <a-select-option value="danger">危险</a-select-option>
+                    </a-select>
+                </a-form-item>
+                <a-form-item label="是否默认" name="is_default">
+                    <a-radio-group v-model:value="dictDataFormState.is_default">
+                        <a-radio :value="1">是</a-radio>
+                        <a-radio :value="0">否</a-radio>
+                    </a-radio-group>
+                </a-form-item>
+                <a-form-item label="状态" name="status">
+                    <a-radio-group v-model:value="dictDataFormState.status">
+                        <a-radio :value="1">正常</a-radio>
+                        <a-radio :value="0">停用</a-radio>
+                    </a-radio-group>
+                </a-form-item>
+                <a-form-item label="备注" name="remark">
+                    <a-textarea v-model:value="dictDataFormState.remark" placeholder="请填写备注" :rows="3" />
+                </a-form-item>
+                <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+                    <a-button type="primary" html-type="submit" :loading="dictDataSubmitLoading">保存</a-button>
+                    <a-button style="margin-left: 10px" @click="openDictDataFormModal = false">取消</a-button>
+                </a-form-item>
+            </a-form>
+        </a-modal>
+    </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { onMounted, ref, reactive, h } from 'vue';
+import { message } from 'ant-design-vue';
+import { PlusCircleOutlined } from '@ant-design/icons-vue';
+import {
+    getDictTypes,
+    saveDictType,
+    deleteDictType,
+    getDictData,
+    saveDictData,
+    deleteDictData,
+    getAllDictTypes
+} from '@/api/dict.js';
+
+/**
+ * 页面全局变量
+ */
+// 字典类型表格列配置
+const dictTypeColumns = [
+    {
+        title: '字典名称',
+        dataIndex: 'dict_name'
+    },
+    {
+        title: '字典类型',
+        dataIndex: 'dict_type'
+    },
+    {
+        title: '状态',
+        dataIndex: 'status',
+        customRender: ({ record }) => {
+            return record.status === 1 ? '正常' : '停用';
+        }
+    },
+    {
+        title: '备注',
+        dataIndex: 'remark'
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at'
+    },
+    {
+        title: '操作',
+        type: 'action',
+        options: [
+            {
+                title: '字典数据',
+                type: 'data'
+            },
+            {
+                title: '编辑',
+                type: 'edit'
+            },
+            {
+                title: '删除',
+                type: 'delete'
+            }
+        ]
+    }
+];
+
+// 字典数据表格列配置
+const dictDataTableColumns = [
+    {
+        title: '字典标签',
+        dataIndex: 'dict_label',
+        width: 150
+    },
+    {
+        title: '字典键值',
+        dataIndex: 'dict_value',
+        width: 150
+    },
+    {
+        title: '显示排序',
+        dataIndex: 'dict_sort',
+        width: 100
+    },
+    {
+        title: '状态',
+        dataIndex: 'status',
+        width: 80
+    },
+    {
+        title: '是否默认',
+        dataIndex: 'is_default',
+        width: 100
+    },
+    {
+        title: '备注',
+        dataIndex: 'remark',
+        width: 200
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at',
+        width: 180
+    },
+    {
+        title: '操作',
+        key: 'action',
+        width: 120,
+        fixed: 'right'
+    }
+];
+
+// 表单相关
+const labelCol = {
+    style: {
+        width: '70px',
+    },
+};
+const dictTypeListRef = ref();
+const dictTypeFormRef = ref();
+const dictDataFormRef = ref();
+
+// 字典类型相关状态
+const openDictTypeModal = ref(false);
+const dictTypeSubmitLoading = ref(false);
+const dictTypeFormState = reactive({
+    id: '',
+    dict_name: '',
+    dict_type: '',
+    status: 1,
+    remark: ''
+});
+
+// 字典数据相关状态
+const openDictDataModal = ref(false);
+const openDictDataFormModal = ref(false);
+const dictDataSubmitLoading = ref(false);
+const currentDictType = ref(null);
+const dictDataFormState = reactive({
+    id: '',
+    dict_type: '',
+    dict_label: '',
+    dict_value: '',
+    dict_sort: 0,
+    css_class: '',
+    list_class: 'default',
+    is_default: 0,
+    status: 1,
+    remark: ''
+});
+
+// 字典数据表格相关状态
+const dictDataList = ref([]);
+const dictDataLoading = ref(false);
+const dictDataPagination = ref({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+    showSizeChanger: true,
+    showQuickJumper: true,
+    showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
+    onChange: (page, pageSize) => {
+        dictDataPagination.value.current = page;
+        dictDataPagination.value.pageSize = pageSize;
+        loadDictDataList();
+    },
+    onShowSizeChange: (current, size) => {
+        dictDataPagination.value.current = 1;
+        dictDataPagination.value.pageSize = size;
+        loadDictDataList();
+    }
+});
+const showPagination = ref(true);
+
+// 行内编辑相关状态
+const editingKey = ref('');
+const originalRecord = ref(null);
+const saveEditLoading = ref(false);
+
+// 字典类型选择
+const dictTypeOptions = ref([]);
+
+// 表单验证规则
+const dictTypeRules = {
+    dict_name: [
+        {
+            required: true,
+            message: '请填写字典名称',
+            trigger: 'change',
+        },
+        {
+            max: 100,
+            message: '字典名称不能超过100个字符',
+            trigger: 'change',
+        }
+    ],
+    dict_type: [
+        {
+            required: true,
+            message: '请填写字典类型',
+            trigger: 'change',
+        },
+        {
+            max: 100,
+            message: '字典类型不能超过100个字符',
+            trigger: 'change',
+        },
+        {
+            pattern: /^[a-zA-Z0-9_]+$/,
+            message: '字典类型只能包含字母、数字和下划线',
+            trigger: 'change',
+        }
+    ],
+    status: [
+        {
+            required: true,
+            message: '请选择状态',
+            trigger: 'change',
+        }
+    ]
+};
+
+const dictDataRules = {
+    dict_type: [
+        {
+            required: true,
+            message: '请选择字典类型',
+            trigger: 'change',
+        }
+    ],
+    dict_label: [
+        {
+            required: true,
+            message: '请填写数据标签',
+            trigger: 'change',
+        },
+        {
+            max: 100,
+            message: '数据标签不能超过100个字符',
+            trigger: 'change',
+        }
+    ],
+    dict_value: [
+        {
+            required: true,
+            message: '请填写数据键值',
+            trigger: 'change',
+        },
+        {
+            max: 100,
+            message: '数据键值不能超过100个字符',
+            trigger: 'change',
+        }
+    ],
+    is_default: [
+        {
+            required: true,
+            message: '请选择是否默认',
+            trigger: 'change',
+        }
+    ],
+    status: [
+        {
+            required: true,
+            message: '请选择状态',
+            trigger: 'change',
+        }
+    ]
+};
+
+/**
+ * 页面生命周期
+ */
+onMounted(() => {
+    loadDictTypes();
+});
+
+/**
+ * 页面函数
+ */
+
+// 加载字典类型选项
+const loadDictTypes = async () => {
+    try {
+        const res = await getAllDictTypes();
+        if (res.code === 200) {
+            dictTypeOptions.value = res.result;
+        }
+    } catch (error) {
+        console.error('加载字典类型失败:', error);
+    }
+};
+
+// 重置字典类型表单
+function resetDictTypeForm() {
+    dictTypeFormRef.value?.resetFields();
+}
+
+// 重置字典数据表单
+function resetDictDataForm() {
+    dictDataFormRef.value?.resetFields();
+}
+
+// 字典类型操作
+const onAddDictType = () => {
+    Object.assign(dictTypeFormState, {
+        id: '',
+        dict_name: '',
+        dict_type: '',
+        status: 1,
+        remark: ''
+    });
+    resetDictTypeForm();
+    openDictTypeModal.value = true;
+};
+
+const handleDictTypeAction = (item, record) => {
+    const type = item.type;
+    if (type === 'edit') {
+        Object.assign(dictTypeFormState, record);
+        openDictTypeModal.value = true;
+    } else if (type === 'data') {
+        currentDictType.value = record;
+        openDictDataModal.value = true;
+        // 重置分页并加载数据
+        dictDataPagination.value.current = 1;
+        loadDictDataList();
+    }
+};
+
+// 加载字典数据列表
+const loadDictDataList = async () => {
+    if (!currentDictType.value?.dict_type) return;
+    
+    dictDataLoading.value = true;
+    try {
+        const params = {
+            page: dictDataPagination.value.current,
+            pageSize: dictDataPagination.value.pageSize,
+            queryForm: { dict_type: currentDictType.value.dict_type }
+        };
+        
+        const res = await getDictData(params);
+         if (res.code === 200) {
+             dictDataList.value = res.result.list || [];
+             const total = res.result.total || 0;
+             
+             // 如果只有一页数据,隐藏分页
+             showPagination.value = total > dictDataPagination.value.pageSize;
+             
+             dictDataPagination.value.current = res.result.page || 1;
+             dictDataPagination.value.total = total;
+         }
+    } catch (error) {
+        console.error('加载字典数据失败:', error);
+        notifyError(error, '加载字典数据失败');
+    } finally {
+        dictDataLoading.value = false;
+    }
+};
+
+// 开始编辑
+const startEdit = (record) => {
+    editingKey.value = record.id;
+    originalRecord.value = { ...record };
+};
+
+// 取消编辑
+const cancelEdit = () => {
+    if (originalRecord.value) {
+        const index = dictDataList.value.findIndex(item => item.id === editingKey.value);
+        if (index > -1) {
+            dictDataList.value[index] = { ...originalRecord.value };
+        }
+    }
+    editingKey.value = '';
+    originalRecord.value = null;
+};
+
+// 保存编辑
+const saveEdit = async (record) => {
+    if (saveEditLoading.value) return; // 防止重复提交
+    
+    saveEditLoading.value = true;
+    try {
+        const res = await saveDictData(record);
+        if (res.code === 200) {
+            message.success('保存成功');
+            editingKey.value = '';
+            originalRecord.value = null;
+            loadDictDataList();
+        }
+    } catch (error) {
+        console.error('保存失败:', error);
+        notifyError(error, '保存失败');
+        cancelEdit();
+    } finally {
+        saveEditLoading.value = false;
+    }
+};
+
+// 删除字典数据项
+const deleteDictDataItem = async (id) => {
+    try {
+        const res = await deleteDictData({ id });
+        if (res.code === 200) {
+            message.success('删除成功');
+            loadDictDataList();
+        }
+    } catch (error) {
+        console.error('删除失败:', error);
+        notifyError(error, '删除失败');
+    }
+};
+
+const onDictTypeSubmit = async () => {
+    dictTypeSubmitLoading.value = true;
+    try {
+        const res = await saveDictType(dictTypeFormState);
+        if (res.code === 200) {
+            message.success(res.message);
+            openDictTypeModal.value = false;
+            dictTypeListRef.value.refresh();
+            loadDictTypes(); // 重新加载字典类型选项
+        }
+    } catch (error) {
+        console.error('保存字典类型失败:', error);
+        notifyError(error, '保存失败');
+    } finally {
+        dictTypeSubmitLoading.value = false;
+    }
+};
+
+// 字典数据操作
+const onAddDictData = () => {
+    Object.assign(dictDataFormState, {
+        id: '',
+        dict_type: currentDictType.value?.dict_type || '',
+        dict_label: '',
+        dict_value: '',
+        dict_sort: 0,
+        css_class: '',
+        list_class: 'default',
+        is_default: 0,
+        status: 1,
+        remark: ''
+    });
+    resetDictDataForm();
+    openDictDataFormModal.value = true;
+};
+
+const onDictDataSubmit = async () => {
+    dictDataSubmitLoading.value = true;
+    try {
+        const res = await saveDictData(dictDataFormState);
+        if (res.code === 200) {
+            message.success(res.message);
+            openDictDataFormModal.value = false;
+            loadDictDataList();
+        }
+    } catch (error) {
+        console.error('保存字典数据失败:', error);
+        notifyError(error, '保存失败');
+    } finally {
+        dictDataSubmitLoading.value = false;
+    }
+};
+</script>
+
+<style lang="less" scoped></style>

+ 427 - 0
src/views/system/Logs.vue

@@ -0,0 +1,427 @@
+<template>
+    <div class="logs-container">
+        <!-- 统计卡片 -->
+        <div class="mb10">
+            <a-row :gutter="16">
+                <a-col :span="6">
+                    <a-card class="animate-on-enter">
+                        <a-statistic title="总请求数" :value="statistics.total_requests"
+                            :value-style="{ color: 'var(--meow-color-primary)' }" />
+                    </a-card>
+                </a-col>
+                <a-col :span="6">
+                    <a-card class="animate-on-enter animate-delay-1">
+                        <a-statistic title="成功请求" :value="statistics.success_requests"
+                            :value-style="{ color: '#52c41a' }" />
+                    </a-card>
+                </a-col>
+                <a-col :span="6">
+                    <a-card class="animate-on-enter animate-delay-2">
+                        <a-statistic title="失败请求" :value="statistics.failed_requests"
+                            :value-style="{ color: '#ff4d4f' }" />
+                    </a-card>
+                </a-col>
+                <a-col :span="6">
+                    <a-card class="animate-on-enter animate-delay-3">
+                        <a-statistic title="成功率" :value="statistics.success_rate" suffix="%"
+                            :value-style="{ color: '#722ed1' }" />
+                    </a-card>
+                </a-col>
+            </a-row>
+        </div>
+
+        <!-- 搜索表单 -->
+        <div class="mb10 animate-on-enter animate-delay-4">
+            <SearchForm :searchForm="searchForm" :searchFields="searchFields" :loading="loading" :maxFirstRowItems="3"
+                @search="handleSearch" @reset="handleReset" />
+        </div>
+
+        <!-- 操作按钮 -->
+        <div class="mb10 animate-on-enter animate-delay-5">
+            <a-space>
+                <a-button type="primary" danger @click="showClearModal">
+                    <delete-outlined /> 清理日志
+                </a-button>
+                <a-button @click="refreshData">
+                    <reload-outlined /> 刷新
+                </a-button>
+            </a-space>
+        </div>
+
+        <!-- 日志表格 -->
+        <div class="card-wrap animate-on-enter animate-delay-6">
+            <DataList ref="DataListRef" :api="(params) => getListByPage(params)" :columns="tableColumns">
+                <template #user_type="{ record }">
+                    <a-tag :color="getUserTypeColor(record.user_type)">{{ record.operator_type_text }}</a-tag>
+                </template>
+                <template #method="{ record }">
+                    <a-tag :color="getMethodColor(record.method)">{{ record.method }}</a-tag>
+                </template>
+                <template #is_success="{ record }">
+                    <a-tag :color="record.is_success ? 'success' : 'error'">
+                        {{ record.is_success ? '成功' : '失败' }}
+                    </a-tag>
+                </template>
+                <template #duration="{ record }">
+                    <span :class="{ 'slow-request': record.duration > 1000 }">
+                        {{ record.duration }}ms
+                    </span>
+                </template>
+                <template #action="{ record }">
+                    <a-button type="link" size="small" @click="showDetail(record)">
+                        <eye-outlined /> 详情
+                    </a-button>
+                </template>
+            </DataList>
+        </div>
+
+        <!-- 详情弹窗 -->
+        <a-modal v-model:open="detailVisible" title="日志详情" width="800px" :footer="null">
+            <div v-if="currentLog" class="log-detail">
+                <a-descriptions :column="2" bordered>
+                    <a-descriptions-item label="操作描述">{{ currentLog.description }}</a-descriptions-item>
+                    <a-descriptions-item label="操作人">{{ currentLog.admin_name }}</a-descriptions-item>
+                    <a-descriptions-item label="请求方法">
+                        <a-tag :color="getMethodColor(currentLog.method)">{{ currentLog.method }}</a-tag>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="请求URI">{{ currentLog.uri }}</a-descriptions-item>
+                    <a-descriptions-item label="控制器">{{ currentLog.controller }}</a-descriptions-item>
+                    <a-descriptions-item label="方法">{{ currentLog.action }}</a-descriptions-item>
+                    <a-descriptions-item label="状态">
+                        <a-tag :color="currentLog.is_success ? 'success' : 'error'">
+                            {{ currentLog.is_success ? '成功' : '失败' }}
+                        </a-tag>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="响应时间">{{ currentLog.duration }}ms</a-descriptions-item>
+                    <a-descriptions-item label="IP地址">{{ currentLog.ip }}</a-descriptions-item>
+                    <a-descriptions-item label="操作时间">{{ currentLog.created_at }}</a-descriptions-item>
+                </a-descriptions>
+
+                <div class="request-data"
+                    v-if="currentLog.request_data && Object.keys(currentLog.request_data).length > 0">
+                    <h4>请求参数</h4>
+                    <pre>{{ JSON.stringify(currentLog.request_data, null, 2) }}</pre>
+                </div>
+
+                <div class="user-agent" v-if="currentLog.user_agent">
+                    <h4>用户代理</h4>
+                    <p>{{ currentLog.user_agent }}</p>
+                </div>
+            </div>
+        </a-modal>
+
+        <!-- 清理日志弹窗 -->
+        <a-modal v-model:open="clearVisible" title="清理日志" @ok="handleClearLogs" :confirm-loading="clearLoading">
+            <a-radio-group v-model:value="clearType" style="margin-bottom: 16px">
+                <a-radio :value="'days'">按天数清理</a-radio>
+                <a-radio :value="'all'">清理全部</a-radio>
+            </a-radio-group>
+
+            <template v-if="clearType === 'days'">
+                <p>请选择要清理多少天前的日志:</p>
+                <a-input-number v-model:value="clearDays" :min="1" :max="365" style="width: 200px" /> 天
+            </template>
+
+            <template v-else>
+                <p>将清理所有日志记录</p>
+            </template>
+
+            <p style="margin-top: 16px; color: #ff4d4f;">
+                <exclamation-circle-outlined /> 此操作不可恢复,请谨慎操作!
+            </p>
+        </a-modal>
+    </div>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { ref, reactive, onMounted } from 'vue'
+import { message } from 'ant-design-vue'
+import {
+    SearchOutlined,
+    ReloadOutlined,
+    DeleteOutlined,
+    EyeOutlined,
+    ExclamationCircleOutlined
+} from '@ant-design/icons-vue'
+import { getLogDetail, clearLogs, getStatistics, getListByPage } from '@/api/logs'
+import DataList from '@/components/DataList.vue'
+import SearchForm from '@/components/SearchForm.vue'
+import dayjs from 'dayjs'
+
+// 响应式数据
+const DataListRef = ref()
+const loading = ref(false)
+const statistics = ref({
+    total_requests: 0,
+    success_requests: 0,
+    failed_requests: 0,
+    success_rate: 0
+})
+
+// 搜索表单
+const searchForm = reactive({
+    user_type: undefined,
+    method: undefined,
+    controller: '',
+    is_success: undefined,
+    date_range: [],
+
+})
+
+// 搜索字段定义
+const searchFields = [
+    {
+        prop: 'user_type',
+        label: '用户类型',
+        type: 'select',
+        placeholder: '选择用户类型',
+        options: [
+            { value: 'admin', label: '管理员' },
+            { value: 'user', label: '用户' },
+            { value: 'guest', label: '访客' }
+        ]
+    },
+    {
+        prop: 'method',
+        label: '请求方法',
+        type: 'select',
+        placeholder: '选择请求方法',
+        options: [
+            { value: 'GET', label: 'GET' },
+            { value: 'POST', label: 'POST' },
+            { value: 'PUT', label: 'PUT' },
+            { value: 'DELETE', label: 'DELETE' }
+        ]
+    },
+    {
+        prop: 'controller',
+        label: '控制器',
+        type: 'input',
+        placeholder: '输入控制器名称'
+    },
+    {
+        prop: 'is_success',
+        label: '状态',
+        type: 'select',
+        placeholder: '选择状态',
+        options: [
+            { value: 1, label: '成功' },
+            { value: 0, label: '失败' }
+        ]
+    },
+    {
+        prop: 'date_range',
+        label: '日期范围',
+        type: 'daterange'
+    }
+]
+
+
+
+// 详情弹窗
+const detailVisible = ref(false)
+const currentLog = ref(null)
+
+// 清理弹窗
+const clearVisible = ref(false)
+const clearLoading = ref(false)
+const clearDays = ref(30)
+const clearType = ref('days')
+
+// 表格列定义
+const tableColumns = [
+    {
+        title: '操作描述',
+        dataIndex: 'description'
+    },
+    {
+        title: '用户类型',
+        dataIndex: 'user_type',
+        slot: true,
+        align: 'center'
+    },
+    {
+        title: '操作人',
+        dataIndex: 'operator_name'
+    },
+    {
+        title: '请求方法',
+        dataIndex: 'method',
+        slot: true,
+        align: 'center'
+    },
+    {
+        title: '控制器/方法',
+        customRender: ({ record }) => `${record.controller}/${record.action}`
+    },
+    {
+        title: '状态',
+        dataIndex: 'is_success',
+        slot: true,
+        align: 'center'
+    },
+    {
+        title: '响应时间',
+        dataIndex: 'duration',
+        slot: true
+    },
+    {
+        title: 'IP地址',
+        dataIndex: 'ip'
+    },
+    {
+        title: '操作时间',
+        dataIndex: 'created_at'
+    },
+    {
+        title: '操作',
+        dataIndex: 'action',
+        slot: true,
+        align: 'center'
+    }
+]
+
+// 获取日志数据的API函数(用于DataList组件)
+
+
+const fetchStatistics = async () => {
+    try {
+        const response = await getStatistics({ days: 7 })
+        if (response.code === 200) {
+            statistics.value = response.result
+        }
+    } catch (error) {
+        console.error('获取统计数据失败:', error)
+    }
+}
+
+const handleSearch = (searchData) => {
+    // 更新搜索表单数据
+    Object.assign(searchForm, searchData)
+    DataListRef.value?.search(searchData)
+}
+
+const handleReset = (resetData) => {
+    // 更新搜索表单数据
+    Object.assign(searchForm, resetData)
+    DataListRef.value?.reset()
+}
+
+
+
+const showDetail = async (record) => {
+    try {
+        const response = await getLogDetail({ id: record.id })
+        if (response.code === 200) {
+            currentLog.value = response.result
+            detailVisible.value = true
+        }
+    } catch (error) {
+        notifyError(error, '获取日志详情失败')
+    }
+}
+
+const showClearModal = () => {
+    clearVisible.value = true
+}
+
+const handleClearLogs = async () => {
+    clearLoading.value = true
+    try {
+        const params = clearType.value === 'all' ? { all: true } : { days: clearDays.value }
+        const response = await clearLogs(params)
+        if (response.code === 200) {
+            message.success(response.message)
+            clearVisible.value = false
+            refreshData()
+        }
+    } catch (error) {
+        notifyError(error, '清理日志失败')
+    } finally {
+        clearLoading.value = false
+    }
+}
+
+const refreshData = () => {
+    DataListRef.value?.refreshData()
+    fetchStatistics()
+}
+
+const getMethodColor = (method) => {
+    const colors = {
+        'GET': 'blue',
+        'POST': 'green',
+        'PUT': 'orange',
+        'DELETE': 'red',
+        'PATCH': 'purple'
+    }
+    return colors[method] || 'default'
+}
+
+const getUserTypeColor = (userType) => {
+    const colors = {
+        'admin': 'blue',
+        'user': 'green',
+        'guest': 'default'
+    }
+    return colors[userType] || 'default'
+}
+
+// 生命周期
+onMounted(() => {
+    fetchStatistics()
+})
+</script>
+
+<style lang="less" scoped>
+.logs-container {
+    padding: 24px;
+
+    .stats-cards {
+        margin-bottom: 24px;
+    }
+
+
+    .log-detail {
+        .request-data {
+            margin-top: 16px;
+
+            h4 {
+                margin-bottom: 8px;
+                color: var(--meow-color-primary);
+            }
+
+            pre {
+                background: #f5f5f5;
+                padding: 12px;
+                border-radius: 4px;
+                max-height: 200px;
+                overflow-y: auto;
+            }
+        }
+
+        .user-agent {
+            margin-top: 16px;
+
+            h4 {
+                margin-bottom: 8px;
+                color: var(--meow-color-primary);
+            }
+
+            p {
+                background: #f5f5f5;
+                padding: 8px;
+                border-radius: 4px;
+                word-break: break-all;
+            }
+        }
+    }
+
+    .slow-request {
+        color: #ff4d4f;
+        font-weight: bold;
+    }
+}
+</style>

+ 467 - 0
src/views/system/Permission.vue

@@ -0,0 +1,467 @@
+<template>
+    <div class="permission-page">
+        <div class="card-wrap mb10 animate-on-enter">
+            <a-space wrap>
+                <a-button type="primary" :icon="h(PlusCircleOutlined)" @click="onAddMenu">新增</a-button>
+            </a-space>
+        </div>
+        <div class="card-wrap animate-on-enter animate-delay-1">
+            <DataList ref="DataListRef" :api="getAllMenus" :deleteApi="deleteMenus" :columns="tableColumns"
+                @action="handleAction"></DataList>
+        </div>
+        <!-- 新增菜单弹窗 -->
+        <a-modal v-model:open="openMenuModal" title="新增菜单" :maskClosable="false" :footer="null">
+        <a-form ref="menuFormRef" :model="formState" :label-col="labelCol" :rules="rules" @finish="onMenuSubmit">
+            <a-form-item label="类型" name="type">
+                <a-radio-group v-model:value="formState.type">
+                    <a-radio :value="1">菜单</a-radio>
+                    <a-radio :value="2">接口</a-radio>
+                </a-radio-group>
+            </a-form-item>
+            <a-form-item label="父级菜单" name="parent_id">
+                <a-tree-select v-model:value="formState.parent_id" placeholder="默认为顶级菜单" :tree-data="parentMenus"
+                    :field-names="{ label: 'title', value: 'id', children: 'children' }"
+                    tree-default-expand-all></a-tree-select>
+            </a-form-item>
+            <a-form-item :label="formState.type === 1 ? '菜单标题' : '接口名称'" name="title">
+                <a-input v-model:value="formState.title" :placeholder="formState.type === 1 ? '请填写菜单标题' : '请填写接口名称'" />
+            </a-form-item>
+            <a-form-item v-if="formState.type === 1" label="菜单标识" name="name">
+                <a-input v-model:value="formState.name" :placeholder="formState.type === 1 ? '请填写菜单标识' : '请填写接口标识'" />
+            </a-form-item>
+            <a-form-item :label="formState.type === 1 ? '访问URL' : '接口路径'" name="path">
+                <a-input v-model:value="formState.path" :placeholder="formState.type === 1 ? '请填写访问URL' : '请填写接口路径'" />
+            </a-form-item>
+            <a-form-item label="组件路径" name="component" v-if="formState.type === 1">
+                <a-input v-model:value="formState.component" placeholder="请填写组件路径" />
+            </a-form-item>
+            <a-form-item label="菜单图标" name="icon" v-if="formState.type === 1">
+                <div class="icon-selector">
+                    <div class="selected-icon" @click="showIconPicker = !showIconPicker">
+                        <div class="icon-display">
+                            <component v-if="formState.icon" :is="getIconComponent(formState.icon)" style="font-size: 16px;" />
+                            <span v-else class="placeholder">请选择菜单图标</span>
+                        </div>
+                        <DownOutlined :style="{ transform: showIconPicker ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.3s' }" />
+                    </div>
+                    <div v-if="showIconPicker" class="icon-picker">
+                        <div class="icon-search">
+                            <a-input v-model:value="iconSearchText" placeholder="搜索图标" prefix="🔍" />
+                        </div>
+                        <div class="icon-grid">
+                            <div v-for="icon in filteredIcons" :key="icon.value"
+                                :class="['icon-item', { 'selected': formState.icon === icon.value }]"
+                                @click="selectIcon(icon.value)">
+                                <component :is="icon.value" />
+                                <span class="icon-name">{{ icon.label }}</span>
+                            </div>
+                        </div>
+                        <div class="icon-actions">
+                            <a-button size="small" @click="clearIcon">清除</a-button>
+                            <a-button size="small" type="primary" @click="showIconPicker = false">确定</a-button>
+                        </div>
+                    </div>
+                </div>
+            </a-form-item>
+            <a-form-item label="排序" name="sort">
+                <a-input-number v-model:value="formState.sort" style="width: 100%;" placeholder="请填写排序" />
+            </a-form-item>
+            <a-form-item label="状态" name="status">
+                <a-radio-group v-model:value="formState.status">
+                    <a-radio :value="1">开启</a-radio>
+                    <a-radio :value="0">禁用</a-radio>
+                </a-radio-group>
+            </a-form-item>
+            <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+                <a-button type="primary" html-type="submit">保存</a-button>
+                <a-button style="margin-left: 10px" @click="openMenuModal = false">取消</a-button>
+            </a-form-item>
+        </a-form>
+        </a-modal>
+    </div>
+</template>
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { onMounted, h, ref, reactive, nextTick, computed, getCurrentInstance } from 'vue';
+import { getAllMenus, saveMenus, deleteMenus } from '@/api/auth.js'
+import { PlusCircleOutlined, DownOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+/**
+ *页面全局变量
+*/
+const tableColumns = [
+    {
+        title: '标题',
+        dataIndex: 'title',
+        width: 200
+    },
+    {
+        title: '权限标识',
+        dataIndex: 'name'
+    },
+    {
+        title: '访问URL/API',
+        dataIndex: 'path',
+        width: 150
+    },
+    {
+        title: '组件路径',
+        dataIndex: 'component',
+        width: 150
+    },
+    {
+        title: '权限',
+        dataIndex: 'permission'
+    },
+    {
+        title: '状态',
+        dataIndex: 'status'
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at'
+    },
+    {
+        type: 'action',
+        title: "操作",
+        align: 'center',
+        options: [
+            {
+                type: 'delete',
+                key: 'delete',
+                title: '删除'
+            },
+            {
+                key: 'edit',
+                title: '编辑'
+            }
+        ]
+    }
+]
+
+const rules = {
+    type: [
+        {
+            required: true,
+            message: '请选择类型',
+            trigger: 'change',
+        }
+    ],
+    title: [
+        {
+            required: true,
+            message: '请填写标题',
+            trigger: 'change',
+        },
+        {
+            min: 2,
+            max: 20,
+            message: '长度为2到20个字符',
+            trigger: 'change',
+        },
+    ],
+    name: [
+        {
+            required: true,
+            message: '请填写标识',
+            trigger: 'change',
+        }
+    ],
+    path: [
+        {
+            required: true,
+            message: '请填写路径',
+            trigger: 'change',
+        }
+    ],
+    component: [
+        {
+            required: true,
+            message: '请填写组件路径',
+            trigger: 'change',
+        }
+    ]
+}
+
+const labelCol = {
+    style: {
+        width: '70px',
+    },
+};
+
+const openMenuModal = ref(false)
+const parentMenus = ref([])
+const menuFormRef = ref();
+const DataListRef = ref();
+const showIconPicker = ref(false)
+const iconSearchText = ref('')
+const formState = reactive({
+    id: null,
+    type: 1,
+    parent_id: "0",
+    title: "",
+    name: "",
+    path: "",
+    component: "",
+    icon: "",
+    sort: 0,
+    status: 1,
+})
+
+// 获取全局注册的图标
+const instance = getCurrentInstance()
+const antIcons = instance?.appContext.config.globalProperties.$antIcons || {}
+
+// 生成图标选项数组
+const iconOptions = Object.keys(antIcons)
+    .filter(key => key.endsWith('Outlined') && key !== 'createFromIconfontCN')
+    .map(key => ({
+        value: key,
+        label: key.replace('Outlined', '').replace(/([A-Z])/g, ' $1').trim(),
+        component: key
+    }))
+    .sort((a, b) => a.label.localeCompare(b.label))
+
+// 计算属性:过滤图标
+const filteredIcons = computed(() => {
+    if (!iconSearchText.value) {
+        return iconOptions
+    }
+    return iconOptions.filter(icon =>
+        icon.label.toLowerCase().includes(iconSearchText.value.toLowerCase()) ||
+        icon.value.toLowerCase().includes(iconSearchText.value.toLowerCase())
+    )
+})
+
+
+/**
+ *页面生命周期
+*/
+onMounted(() => {
+    getAllMenusData()
+})
+
+
+/**
+ *页面函数
+*/
+
+function onAddMenu() {
+    openMenuModal.value = true
+
+    if (menuFormRef.value) menuFormRef.value.resetFields()
+}
+
+function getAllMenusData() {
+    getAllMenus().then(res => {
+        if (res.code == 200) {
+
+            const defaultObj = {
+                id: "0",
+                title: '顶级菜单',
+                disabled: true
+            }
+
+            parentMenus.value = [defaultObj, ...res.result];
+        }
+    }).catch(error => notifyError(error))
+}
+
+function onMenuSubmit() {
+    saveMenus(formState).then(res => {
+        if (res.code == 200) {
+            message.success(res.message)
+            openMenuModal.value = false
+            DataListRef.value.refreshData()
+        }
+    }).catch(error => notifyError(error))
+}
+
+function handleAction(row, data) {
+
+    if (row.key == 'edit') {
+        menuEdit(data)
+    }
+}
+
+function menuEdit(data) {
+    openMenuModal.value = true;
+
+    nextTick(() => {
+        Object.assign(formState, data)
+    })
+}
+
+// 图标选择器相关方法
+function selectIcon(iconValue) {
+    formState.icon = iconValue
+    showIconPicker.value = false
+    iconSearchText.value = ''
+}
+
+function clearIcon() {
+    formState.icon = ''
+    showIconPicker.value = false
+    iconSearchText.value = ''
+}
+
+function getIconComponent(iconValue) {
+    return iconValue
+}
+
+function getIconLabel(iconValue) {
+    const icon = iconOptions.find(item => item.value === iconValue)
+    return icon ? icon.label : ''
+}
+</script>
+<style lang="less" scoped>
+.icon-selector {
+    position: relative;
+    width: 100%;
+
+    .selected-icon {
+        display: flex;
+        align-items: center;
+        padding: 4px 11px;
+        min-height: 32px;
+        border: 1px solid #d9d9d9;
+        border-radius: 6px;
+        cursor: pointer;
+        background: #fff;
+        transition: all 0.3s;
+        font-size: 14px;
+        line-height: 1.5715;
+
+        &:hover {
+            border-color: #366ef4;
+        }
+
+        &:focus {
+            border-color: #366ef4;
+            box-shadow: 0 0 0 2px rgba(0, 82, 217, 0.2);
+            outline: 0;
+        }
+
+        .icon-display {
+            flex: 1;
+            display: flex;
+            align-items: center;
+            
+            .anticon {
+                color: #000000d9;
+            }
+            
+            .placeholder {
+                color: #bfbfbf;
+            }
+        }
+        
+        > .anticon {
+            color: #bfbfbf;
+            font-size: 12px;
+            transition: transform 0.3s;
+            margin-left: 8px;
+        }
+    }
+
+    .icon-picker {
+        position: absolute;
+        top: 100%;
+        left: 0;
+        right: 0;
+        z-index: 1050;
+        background: #fff;
+        border: 1px solid #d9d9d9;
+        border-radius: 6px;
+        box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
+        max-height: 320px;
+        overflow: hidden;
+        margin-top: 4px;
+
+        .icon-search {
+            padding: 12px;
+            border-bottom: 1px solid #f0f0f0;
+
+            .ant-input {
+                border-radius: 6px;
+            }
+        }
+
+        .icon-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
+            gap: 4px;
+            padding: 8px;
+            max-height: 200px;
+            overflow-y: auto;
+
+            &::-webkit-scrollbar {
+                width: 6px;
+            }
+
+            &::-webkit-scrollbar-thumb {
+                background: #d9d9d9;
+                border-radius: 3px;
+            }
+
+            .icon-item {
+                display: flex;
+                flex-direction: column;
+                align-items: center;
+                justify-content: center;
+                padding: 8px 4px;
+                border: 1px solid transparent;
+                border-radius: 6px;
+                cursor: pointer;
+                transition: all 0.2s;
+                background: #fff;
+                min-height: 60px;
+
+                &:hover {
+                    border-color: #d9d9d9;
+                    background: #f5f5f5;
+                }
+
+                &.selected {
+                    border-color: var(--meow-color-primary);
+                    background: var(--meow-color-primary-light);
+                    color: var(--meow-color-primary);
+                }
+
+                .anticon {
+                    font-size: 18px;
+                    margin-bottom: 4px;
+                }
+
+                .icon-name {
+                    font-size: 10px;
+                    text-align: center;
+                    line-height: 1.2;
+                    word-break: break-all;
+                    max-width: 100%;
+                    overflow: hidden;
+                    text-overflow: ellipsis;
+                    display: -webkit-box;
+                    -webkit-line-clamp: 2;
+                    -webkit-box-orient: vertical;
+                }
+            }
+        }
+
+        .icon-actions {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            gap: 8px;
+            padding: 8px 12px;
+            border-top: 1px solid #f0f0f0;
+            background: #fafafa;
+
+            .ant-btn {
+                border-radius: 6px;
+            }
+        }
+    }
+}
+</style>

+ 227 - 0
src/views/system/Role.vue

@@ -0,0 +1,227 @@
+<template>
+    <div class="card-wrap mb10 animate-on-enter">
+        <a-space wrap>
+            <a-button type="primary" :icon="h(PlusCircleOutlined)" @click="onAddRole">新增</a-button>
+        </a-space>
+    </div>
+    <div class="card-wrap animate-on-enter animate-delay-1">
+        <DataList ref="dataListRef" :columns="columns" :api="getRoleListApi" :delete-api="deleteRoleApi"
+            @action="onAction" />
+    </div>
+
+    <!-- 新增/编辑角色弹窗 -->
+    <a-modal v-model:open="openRoleModal" :title="formState.id ? '编辑角色' : '新增角色'" :footer="false">
+        <a-form ref="formRef" :model="formState" :rules="rules" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }"
+            @finish="onRoleSubmit">
+            <a-form-item label="角色名称" name="name">
+                <a-input v-model:value="formState.name" placeholder="请填写角色名称" />
+            </a-form-item>
+            <a-form-item label="角色描述" name="description">
+                <a-textarea v-model:value="formState.description" placeholder="请填写角色描述" :rows="3" />
+            </a-form-item>
+            <a-form-item label="状态" name="status">
+                <a-radio-group v-model:value="formState.status">
+                    <a-radio :value="1">开启</a-radio>
+                    <a-radio :value="0">禁用</a-radio>
+                </a-radio-group>
+            </a-form-item>
+            <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+                <a-button type="primary" html-type="submit">保存</a-button>
+                <a-button style="margin-left: 10px" @click="openRoleModal = false">取消</a-button>
+            </a-form-item>
+        </a-form>
+    </a-modal>
+
+    <!-- 设置权限弹窗 -->
+    <a-modal v-model:open="openPermissionModal" title="设置权限" width="600px" :footer="null">
+        <!-- 创建人分组提示 -->
+        <div v-if="currentRole && currentRole.is_founder === 1"
+            style="margin-bottom: 16px; padding: 16px; background-color: #fff7e6; border: 1px solid #ffd591; border-radius: 6px; color: #d46b08;">
+            <div style="font-size: 14px; margin-bottom: 8px; font-weight: 500;">👑 系统创建人分组</div>
+            <div style="font-size: 13px; line-height: 1.5;">
+                该角色为系统创建人分组,拥有系统最高权限,无需进行权限分配。
+            </div>
+        </div>
+        <!-- 普通角色权限设置说明 -->
+        <div v-else
+            style="margin-bottom: 16px; padding: 12px; background-color: #f6f8fa; border-radius: 6px; color: #666;">
+            <div style="font-size: 14px; margin-bottom: 8px;">💡 权限设置说明:</div>
+            <div style="font-size: 13px; line-height: 1.5;">
+                • 勾选菜单项表示该角色拥有对应的访问权限<br>
+                • 父级菜单被勾选时,子级菜单会自动继承权限<br>
+                • 请根据角色职责合理分配权限,确保系统安全
+            </div>
+        </div>
+        <!-- 只有非创建人分组才显示权限树 -->
+        <a-tree v-if="!currentRole || currentRole.is_founder !== 1" v-model:checkedKeys="checkedMenus"
+            :tree-data="menuTreeData" checkable checkStrictly :default-expand-all="true"
+            :field-names="{ children: 'children', title: 'title', key: 'id' }" />
+        <a-space v-if="currentRole.is_founder != 1" style="margin-top: 40px;">
+            <a-button type="primary" @click="onPermissionSubmit">确定</a-button>
+            <a-button @click="openPermissionModal = false">取消</a-button>
+        </a-space>
+    </a-modal>
+</template>
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { onMounted, ref, reactive, h } from 'vue'
+import { message } from 'ant-design-vue'
+import { PlusCircleOutlined } from '@ant-design/icons-vue'
+import DataList from '@/components/DataList.vue'
+import { getRoles, saveRole, deleteRole, getRoleMenus, getAllMenus, saveRolePermission } from '@/api/auth'
+
+// 响应式数据
+const dataListRef = ref()
+const formRef = ref()
+const openRoleModal = ref(false)
+const openPermissionModal = ref(false)
+const menuTreeData = ref([])
+const checkedMenus = ref([])
+const currentRole = ref(null)
+// 表格列配置
+const columns = [
+    {
+        title: '角色名称',
+        dataIndex: 'name'
+    },
+    {
+        title: '角色描述',
+        dataIndex: 'description'
+    },
+    {
+        title: '状态',
+        dataIndex: 'status'
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at'
+    },
+    {
+        title: '操作',
+        type: 'action',
+        options: [
+            {
+                title: '编辑',
+                type: 'edit'
+            },
+            {
+                title: '设置权限',
+                type: 'permission'
+            },
+            {
+                title: '删除',
+                type: 'delete'
+            }
+        ]
+    }
+]
+
+// 表单数据
+const formState = reactive({
+    id: '',
+    name: '',
+    description: '',
+    status: 1
+})
+
+// 表单验证规则
+const rules = {
+    name: [
+        {
+            required: true,
+            message: '请填写角色名称',
+            trigger: 'change',
+        }
+    ]
+}
+
+// 生命周期
+onMounted(() => {
+    getMenus()
+})
+
+// 获取角色列表API
+const getRoleListApi = () => {
+    return getRoles()
+}
+
+// 删除角色API
+const deleteRoleApi = (record) => {
+    return deleteRole({ id: record.id })
+}
+
+// 获取菜单列表
+function getMenus() {
+    getAllMenus().then(res => {
+        if (res.code == 200) {
+            menuTreeData.value = res.result
+        }
+    }).catch(error => notifyError(error))
+}
+
+
+// 新增角色
+const onAddRole = () => {
+    Object.assign(formState, {
+        id: '',
+        name: '',
+        description: '',
+        status: 1
+    })
+    openRoleModal.value = true
+}
+
+// 处理操作
+const onAction = (item, record) => {
+    if (item.type === 'edit') {
+        Object.assign(formState, record)
+        openRoleModal.value = true
+    } else if (item.type === 'permission') {
+        handleSetPermission(record)
+    }
+}
+
+// 设置权限
+function handleSetPermission(record) {
+    currentRole.value = record
+    getRoleMenus({ role_id: record.id }).then(res => {
+        if (res.code == 200) {
+            checkedMenus.value = res.result
+        }
+
+        openPermissionModal.value = true
+    }).catch(error => notifyError(error))
+}
+
+
+// 提交表单
+function onRoleSubmit() {
+    saveRole(formState).then(res => {
+        if (res.code == 200) {
+            message.success('保存成功')
+            openRoleModal.value = false
+            dataListRef.value.refreshData()
+        }
+    }).catch(error => notifyError(error))
+
+}
+
+
+// 提交权限设置
+function onPermissionSubmit() {
+
+    const params = {
+        id: currentRole.value.id,
+        menu_ids: checkedMenus.value.checked
+    }
+
+    saveRolePermission(params).then(res => {
+        if (res.code == 200) {
+            message.success('权限设置成功')
+            openPermissionModal.value = false
+        }
+    }).catch(error => notifyError(error))
+}
+</script>
+<style lang="scss" scoped></style>

+ 326 - 0
src/views/system/User.vue

@@ -0,0 +1,326 @@
+<template>
+    <div class="card-wrap mb10 animate-on-enter">
+        <a-space wrap>
+            <a-button type="primary" :icon="h(PlusCircleOutlined)" @click="onAddUser">新增</a-button>
+        </a-space>
+    </div>
+    <div class="card-wrap animate-on-enter animate-delay-1">
+        <DataList ref="DataListRef" :api="getUsers" :deleteApi="deleteUser" :columns="tableColumns"
+            @action="handleAction"></DataList>
+    </div>
+
+    <!-- 新增/编辑用户弹窗 -->
+    <a-modal v-model:open="openUserModal" :title="formState.id ? '编辑用户' : '新增用户'" :maskClosable="false" :footer="null">
+        <a-form ref="userFormRef" :model="formState" :label-col="labelCol" :rules="rules" @finish="onUserSubmit">
+            <a-form-item label="用户名" name="username">
+                <a-input v-model:value="formState.username" placeholder="请填写用户名" />
+            </a-form-item>
+            <a-form-item label="姓名" name="real_name">
+                <a-input v-model:value="formState.real_name" placeholder="请填写姓名" />
+            </a-form-item>
+            <a-form-item label="邮箱" name="email">
+                <a-input v-model:value="formState.email" placeholder="请填写邮箱" />
+            </a-form-item>
+            <a-form-item label="密码" name="password">
+                <a-input-password v-model:value="formState.password"
+                    :placeholder="formState.id ? '留空则不修改密码' : '请填写密码'" />
+            </a-form-item>
+            <a-form-item label="所属角色" name="role_id">
+                <a-select v-model:value="formState.role_id" placeholder="请选择角色" :options="roleOptions"
+                    :field-names="{ label: 'name', value: 'id' }"></a-select>
+            </a-form-item>
+            <a-form-item label="状态" name="status">
+                <a-radio-group v-model:value="formState.status">
+                    <a-radio :value="1">开启</a-radio>
+                    <a-radio :value="0">禁用</a-radio>
+                </a-radio-group>
+            </a-form-item>
+            <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+                <a-button type="primary" html-type="submit">保存</a-button>
+                <a-button style="margin-left: 10px" @click="openUserModal = false">取消</a-button>
+            </a-form-item>
+        </a-form>
+    </a-modal>
+
+    <!-- 编辑角色弹窗 -->
+    <a-modal v-model:open="openRoleModal" title="编辑用户角色" :maskClosable="false" :footer="null">
+        <a-form ref="roleFormRef" :model="roleFormState" :label-col="labelCol" :rules="roleRules"
+            @finish="onRoleSubmit">
+            <a-form-item label="用户" name="username">
+                <a-input v-model:value="roleFormState.username" disabled />
+            </a-form-item>
+            <a-form-item label="所属角色" name="role_id">
+                <a-select v-model:value="roleFormState.role_id" placeholder="请选择角色" :options="roleOptions"
+                    :field-names="{ label: 'name', value: 'id' }"></a-select>
+            </a-form-item>
+            <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
+                <a-button type="primary" html-type="submit">保存</a-button>
+                <a-button style="margin-left: 10px" @click="openRoleModal = false">取消</a-button>
+            </a-form-item>
+        </a-form>
+    </a-modal>
+</template>
+
+<script setup>
+import { notifyError } from '@/utils/request.js'
+
+import { onMounted, ref, reactive, h } from 'vue';
+import { message } from 'ant-design-vue';
+import { PlusCircleOutlined } from '@ant-design/icons-vue';
+import { getUsers, saveUser, deleteUser, getRoles } from '@/api/auth.js';
+
+/**
+ * 页面全局变量
+ */
+const tableColumns = [
+    {
+        title: '用户名',
+        dataIndex: 'username'
+    },
+    {
+        title: '姓名',
+        dataIndex: 'real_name'
+    },
+    {
+        title: '邮箱',
+        dataIndex: 'email'
+    },
+    {
+        title: '所属角色',
+        dataIndex: 'role',
+        customRender: ({ record }) => {
+            return record.role ? record.role.name : '-';
+        }
+    },
+    {
+        title: '状态',
+        dataIndex: 'status'
+    },
+    {
+        title: '最后登录时间',
+        dataIndex: 'last_login_time'
+    },
+    {
+        title: '创建时间',
+        dataIndex: 'created_at'
+    },
+    {
+        title: '操作',
+        type: 'action',
+        options: [
+            {
+                title: '编辑',
+                type: 'edit'
+            },
+            {
+                title: '编辑角色',
+                type: 'editRole'
+            },
+            {
+                title: '删除',
+                type: 'delete'
+            }
+        ]
+    }
+];
+
+const rules = {
+    username: [
+        {
+            required: true,
+            message: '请填写用户名',
+            trigger: 'change',
+        },
+        {
+            min: 4,
+            max: 32,
+            message: '用户名长度为4-32个字符',
+            trigger: 'change',
+        }
+    ],
+    real_name: [
+        {
+            required: true,
+            message: '请填写姓名',
+            trigger: 'change',
+        },
+        {
+            max: 50,
+            message: '姓名不能超过50个字符',
+            trigger: 'change',
+        }
+    ],
+    email: [
+        {
+            type: 'email',
+            message: '邮箱格式不正确',
+            trigger: 'change',
+        }
+    ],
+    password: [
+        {
+            required: true,
+            message: '请填写密码',
+            trigger: 'change',
+        },
+        {
+            validator: (rule, value) => {
+                if (!formState.id && !value) {
+                    return Promise.reject('请填写密码');
+                }
+                if (value && (value.length < 8 || value.length > 72)) {
+                    return Promise.reject('密码长度应为8–72个字符');
+                }
+                if (value && !/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]+$/.test(value)) {
+                    return Promise.reject('密码必须包含字母、数字和特殊字符');
+                }
+                return Promise.resolve();
+            },
+            trigger: 'change',
+        }
+    ],
+    role_id: [
+        {
+            required: true,
+            message: '请选择角色',
+            trigger: 'change',
+        }
+    ],
+    status: [
+        {
+            required: true,
+            message: '请选择状态',
+            trigger: 'change',
+        }
+    ]
+};
+
+const roleRules = {
+    role_id: [
+        {
+            required: true,
+            message: '请选择角色',
+            trigger: 'change',
+        }
+    ]
+};
+
+const labelCol = {
+    style: {
+        width: '70px',
+    },
+};
+
+const openUserModal = ref(false);
+const openRoleModal = ref(false);
+const roleOptions = ref([]);
+const userFormRef = ref();
+const roleFormRef = ref();
+const DataListRef = ref();
+
+const formState = reactive({
+    id: null,
+    username: '',
+    nickname: '',
+    email: '',
+    password: '',
+    role_id: null,
+    status: 1
+});
+
+const roleFormState = reactive({
+    id: null,
+    username: '',
+    role_id: null
+});
+
+/**
+ * 页面生命周期
+ */
+onMounted(() => {
+    loadRoles();
+});
+
+/**
+ * 页面函数
+ */
+
+// 加载角色列表
+function loadRoles() {
+    getRoles().then(res => {
+        if (res.code === 200) {
+            roleOptions.value = res.result.list || res.result;
+        }
+    }).catch(error => notifyError(error));
+}
+
+// 新增用户
+function onAddUser() {
+    resetForm();
+    openUserModal.value = true;
+}
+
+// 重置表单
+function resetForm() {
+    userFormRef.value?.resetFields();
+}
+
+// 重置角色表单
+function resetRoleForm() {
+    roleFormRef.value?.resetFields();
+}
+
+// 处理操作
+function handleAction(item, record) {
+    if (item.type === 'edit') {
+        // 编辑用户
+        Object.assign(formState, {
+            id: record.id,
+            username: record.username,
+            nickname: record.nickname,
+            email: record.email || '',
+            password: '',
+            role_id: record.role_id,
+            status: record.status
+        });
+        openUserModal.value = true;
+    } else if (item.type === 'editRole') {
+        // 编辑角色
+        Object.assign(roleFormState, {
+            id: record.id,
+            username: record.username,
+            role_id: record.role_id
+        });
+        openRoleModal.value = true;
+    }
+}
+
+// 提交用户表单
+function onUserSubmit() {
+    saveUser(formState).then(res => {
+        if (res.code === 200) {
+            message.success(res.message);
+            openUserModal.value = false;
+            DataListRef.value.refreshData();
+        }
+    }).catch(error => notifyError(error));
+}
+
+// 提交角色表单
+function onRoleSubmit() {
+    const submitData = {
+        id: roleFormState.id,
+        role_id: roleFormState.role_id
+    };
+
+    saveUser(submitData).then(res => {
+        if (res.code === 200) {
+            message.success(res.message);
+            openRoleModal.value = false;
+            DataListRef.value.refreshData();
+        }
+    }).catch(error => notifyError(error));
+}
+</script>
+
+<style lang="less" scoped></style>

+ 24 - 0
vite.config.js

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+
+export default defineConfig({
+  base: "/admin/",
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  css: {
+    preprocessorOptions: {
+      less: {
+        additionalData: `@import "@/styles/variables.less";`
+      }
+    }
+  },
+  server: {
+    port: 3000,
+    open: true
+  }
+})

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff