Bladeren bron

feat: 同步内容管理与应用端代码

jiapu 4 maanden geleden
bovenliggende
commit
ebfeb7ea0b
45 gewijzigde bestanden met toevoegingen van 3220 en 6 verwijderingen
  1. 17 0
      code/ajyApp/App.vue
  2. 20 0
      code/ajyApp/index.html
  3. 22 0
      code/ajyApp/main.js
  4. 88 0
      code/ajyApp/manifest.json
  5. 17 0
      code/ajyApp/pages.json
  6. 52 0
      code/ajyApp/pages/index/index.vue
  7. BIN
      code/ajyApp/static/logo.png
  8. 13 0
      code/ajyApp/uni.promisify.adaptor.js
  9. 76 0
      code/ajyApp/uni.scss
  10. 12 0
      code/ajyApp/uniCloud-alipay/database/JQL查询.jql
  11. 6 0
      code/ajyApp/uni_modules/uni-config-center/changelog.md
  12. 81 0
      code/ajyApp/uni_modules/uni-config-center/package.json
  13. 93 0
      code/ajyApp/uni_modules/uni-config-center/readme.md
  14. 0 0
      code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/index.js
  15. 13 0
      code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/package.json
  16. 38 0
      code/ajyApp/uni_modules/uni-id-common/changelog.md
  17. 101 0
      code/ajyApp/uni_modules/uni-id-common/package.json
  18. 3 0
      code/ajyApp/uni_modules/uni-id-common/readme.md
  19. 0 0
      code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/index.js
  20. 20 0
      code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/package.json
  21. 7 0
      code/backend/pom.xml
  22. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/OssProperties.java
  23. 21 1
      code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java
  24. 20 2
      code/backend/src/main/java/com/aijiuyi/admin/common/handler/MyMetaObjectHandler.java
  25. 147 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java
  26. 157 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/ContentArticleController.java
  27. 65 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java
  28. 16 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ArticleRejectDTO.java
  29. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ContentArticleQueryDTO.java
  30. 74 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/ContentArticle.java
  31. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/ContentArticleMapper.java
  32. 78 0
      code/backend/src/main/java/com/aijiuyi/admin/service/ContentArticleService.java
  33. 280 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/ContentArticleServiceImpl.java
  34. 23 3
      code/backend/src/main/resources/application-dev.yml
  35. 22 0
      code/backend/src/main/resources/application-test.yml
  36. 6 0
      code/backend/src/main/resources/application.yml
  37. 33 0
      code/backend/src/main/resources/sql/migration_content_article.sql
  38. 537 0
      code/frontend/package-lock.json
  39. 2 0
      code/frontend/package.json
  40. 115 0
      code/frontend/src/api/content.js
  41. 3 0
      code/frontend/src/api/index.js
  42. 136 0
      code/frontend/src/components/RichEditor.vue
  43. 6 0
      code/frontend/src/router/index.js
  44. 607 0
      code/frontend/src/views/content/index.vue
  45. 111 0
      code/内容管理.md

+ 17 - 0
code/ajyApp/App.vue

@@ -0,0 +1,17 @@
+<script>
+	export default {
+		onLaunch: function() {
+			console.log('App Launch')
+		},
+		onShow: function() {
+			console.log('App Show')
+		},
+		onHide: function() {
+			console.log('App Hide')
+		}
+	}
+</script>
+
+<style>
+	/*每个页面公共css */
+</style>

+ 20 - 0
code/ajyApp/index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <script>
+      var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
+        CSS.supports('top: constant(a)'))
+      document.write(
+        '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
+        (coverSupport ? ', viewport-fit=cover' : '') + '" />')
+    </script>
+    <title></title>
+    <!--preload-links-->
+    <!--app-context-->
+  </head>
+  <body>
+    <div id="app"><!--app-html--></div>
+    <script type="module" src="/main.js"></script>
+  </body>
+</html>

+ 22 - 0
code/ajyApp/main.js

@@ -0,0 +1,22 @@
+import App from './App'
+
+// #ifndef VUE3
+import Vue from 'vue'
+import './uni.promisify.adaptor'
+Vue.config.productionTip = false
+App.mpType = 'app'
+const app = new Vue({
+  ...App
+})
+app.$mount()
+// #endif
+
+// #ifdef VUE3
+import { createSSRApp } from 'vue'
+export function createApp() {
+  const app = createSSRApp(App)
+  return {
+    app
+  }
+}
+// #endif

+ 88 - 0
code/ajyApp/manifest.json

@@ -0,0 +1,88 @@
+{
+    "name" : "ajyApp",
+    "appid" : "__UNI__A3304B5",
+    "description" : "",
+    "versionName" : "1.0.0",
+    "versionCode" : "100",
+    "transformPx" : false,
+    /* 5+App特有相关 */
+    "app-plus" : {
+        "usingComponents" : true,
+        "nvueStyleCompiler" : "uni-app",
+        "compilerVersion" : 3,
+        "splashscreen" : {
+            "alwaysShowBeforeRender" : true,
+            "waiting" : true,
+            "autoclose" : true,
+            "delay" : 0
+        },
+        /* 模块配置 */
+        "modules" : {
+            "Bluetooth" : {},
+            "Push" : {}
+        },
+        /* 应用发布信息 */
+        "distribute" : {
+            /* android打包配置 */
+            "android" : {
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+                    "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+                    "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
+                ]
+            },
+            /* ios打包配置 */
+            "ios" : {},
+            /* SDK配置 */
+            "sdkConfigs" : {
+                "push" : {
+                    "unipush" : {
+                        "version" : "2",
+                        "offline" : true,
+                        "hms" : {},
+                        "oppo" : {},
+                        "vivo" : {},
+                        "mi" : {},
+                        "honor" : {}
+                    }
+                }
+            }
+        }
+    },
+    /* 快应用特有相关 */
+    "quickapp" : {},
+    /* 小程序特有相关 */
+    "mp-weixin" : {
+        "appid" : "",
+        "setting" : {
+            "urlCheck" : false
+        },
+        "usingComponents" : true
+    },
+    "mp-alipay" : {
+        "usingComponents" : true
+    },
+    "mp-baidu" : {
+        "usingComponents" : true
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true
+    },
+    "uniStatistics" : {
+        "enable" : false
+    },
+    "vueVersion" : "3",
+    "fallbackLocale" : "zh-Hans"
+}

+ 17 - 0
code/ajyApp/pages.json

@@ -0,0 +1,17 @@
+{
+	"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
+		{
+			"path": "pages/index/index",
+			"style": {
+				"navigationBarTitleText": "uni-app"
+			}
+		}
+	],
+	"globalStyle": {
+		"navigationBarTextStyle": "black",
+		"navigationBarTitleText": "uni-app",
+		"navigationBarBackgroundColor": "#F8F8F8",
+		"backgroundColor": "#F8F8F8"
+	},
+	"uniIdRouter": {}
+}

+ 52 - 0
code/ajyApp/pages/index/index.vue

@@ -0,0 +1,52 @@
+<template>
+	<view class="content">
+		<image class="logo" src="/static/logo.png"></image>
+		<view class="text-area">
+			<text class="title">{{title}}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		data() {
+			return {
+				title: 'Hello'
+			}
+		},
+		onLoad() {
+
+		},
+		methods: {
+
+		}
+	}
+</script>
+
+<style>
+	.content {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+	}
+
+	.logo {
+		height: 200rpx;
+		width: 200rpx;
+		margin-top: 200rpx;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 50rpx;
+	}
+
+	.text-area {
+		display: flex;
+		justify-content: center;
+	}
+
+	.title {
+		font-size: 36rpx;
+		color: #8f8f94;
+	}
+</style>

BIN
code/ajyApp/static/logo.png


+ 13 - 0
code/ajyApp/uni.promisify.adaptor.js

@@ -0,0 +1,13 @@
+uni.addInterceptor({
+  returnValue (res) {
+    if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
+      return res;
+    }
+    return new Promise((resolve, reject) => {
+      res.then((res) => {
+        if (!res) return resolve(res)
+        return res[0] ? reject(res[0]) : resolve(res[1])
+      });
+    });
+  },
+});

+ 76 - 0
code/ajyApp/uni.scss

@@ -0,0 +1,76 @@
+/**
+ * 这里是uni-app内置的常用样式变量
+ *
+ * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
+ * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
+ *
+ */
+
+/**
+ * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
+ *
+ * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
+ */
+
+/* 颜色变量 */
+
+/* 行为相关颜色 */
+$uni-color-primary: #007aff;
+$uni-color-success: #4cd964;
+$uni-color-warning: #f0ad4e;
+$uni-color-error: #dd524d;
+
+/* 文字基本颜色 */
+$uni-text-color:#333;//基本色
+$uni-text-color-inverse:#fff;//反色
+$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
+$uni-text-color-placeholder: #808080;
+$uni-text-color-disable:#c0c0c0;
+
+/* 背景颜色 */
+$uni-bg-color:#ffffff;
+$uni-bg-color-grey:#f8f8f8;
+$uni-bg-color-hover:#f1f1f1;//点击状态颜色
+$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
+
+/* 边框颜色 */
+$uni-border-color:#c8c7cc;
+
+/* 尺寸变量 */
+
+/* 文字尺寸 */
+$uni-font-size-sm:12px;
+$uni-font-size-base:14px;
+$uni-font-size-lg:16px;
+
+/* 图片尺寸 */
+$uni-img-size-sm:20px;
+$uni-img-size-base:26px;
+$uni-img-size-lg:40px;
+
+/* Border Radius */
+$uni-border-radius-sm: 2px;
+$uni-border-radius-base: 3px;
+$uni-border-radius-lg: 6px;
+$uni-border-radius-circle: 50%;
+
+/* 水平间距 */
+$uni-spacing-row-sm: 5px;
+$uni-spacing-row-base: 10px;
+$uni-spacing-row-lg: 15px;
+
+/* 垂直间距 */
+$uni-spacing-col-sm: 4px;
+$uni-spacing-col-base: 8px;
+$uni-spacing-col-lg: 12px;
+
+/* 透明度 */
+$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
+
+/* 文章场景相关 */
+$uni-color-title: #2C405A; // 文章标题颜色
+$uni-font-size-title:20px;
+$uni-color-subtitle: #555555; // 二级标题颜色
+$uni-font-size-subtitle:26px;
+$uni-color-paragraph: #3F536E; // 文章段落颜色
+$uni-font-size-paragraph:15px;

+ 12 - 0
code/ajyApp/uniCloud-alipay/database/JQL查询.jql

@@ -0,0 +1,12 @@
+// 本文件用于,使用JQL语法操作项目关联的uniCloud空间的数据库,方便开发调试和远程数据库管理
+// 编写clientDB的js API(也支持常规js语法,比如var),可以对云数据库进行增删改查操作。不支持uniCloud-db组件写法
+// 可以全部运行,也可以选中部分代码运行。点击工具栏上的运行按钮或者按下【F5】键运行代码
+// 如果文档中存在多条JQL语句,只有最后一条语句生效
+// 如果混写了普通js,最后一条语句需是数据库操作语句
+// 此处代码运行不受DB Schema的权限控制,移植代码到实际业务中注意在schema中配好permission
+// 不支持clientDB的action
+// 数据库查询有最大返回条数限制,详见:https://uniapp.dcloud.net.cn/uniCloud/cf-database.html#limit
+// 详细JQL语法,请参考:https://uniapp.dcloud.net.cn/uniCloud/jql.html
+
+// 下面示例查询uni-id-users表的所有数据
+db.collection('uni-id-users').get();

+ 6 - 0
code/ajyApp/uni_modules/uni-config-center/changelog.md

@@ -0,0 +1,6 @@
+## 0.0.3(2022-11-11)
+- 修复 config 方法获取根节点为数组格式配置时错误的转化为了对象的Bug
+## 0.0.2(2021-04-16)
+- 修改插件package信息
+## 0.0.1(2021-03-15)
+- 初始化项目

+ 81 - 0
code/ajyApp/uni_modules/uni-config-center/package.json

@@ -0,0 +1,81 @@
+{
+  "id": "uni-config-center",
+  "displayName": "uni-config-center",
+  "version": "0.0.3",
+  "description": "uniCloud 配置中心",
+  "keywords": [
+    "配置",
+    "配置中心"
+],
+  "repository": "",
+  "engines": {
+    "HBuilderX": "^3.1.0"
+  },
+"dcloudext": {
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "无",
+      "permissions": "无"
+    },
+    "npmurl": "",
+    "type": "unicloud-template-function"
+  },
+  "directories": {
+    "example": "../../../scripts/dist"
+  },
+  "uni_modules": {
+    "dependencies": [],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "y",
+        "aliyun": "y"
+      },
+      "client": {
+        "App": {
+          "app-vue": "u",
+          "app-nvue": "u"
+        },
+        "H5-mobile": {
+          "Safari": "u",
+          "Android Browser": "u",
+          "微信浏览器(Android)": "u",
+          "QQ浏览器(Android)": "u"
+        },
+        "H5-pc": {
+          "Chrome": "u",
+          "IE": "u",
+          "Edge": "u",
+          "Firefox": "u",
+          "Safari": "u"
+        },
+        "小程序": {
+          "微信": "u",
+          "阿里": "u",
+          "百度": "u",
+          "字节跳动": "u",
+          "QQ": "u"
+        },
+        "快应用": {
+          "华为": "u",
+          "联盟": "u"
+        },
+        "Vue": {
+            "vue2": "y",
+            "vue3": "u"
+        }
+      }
+    }
+  }
+}

+ 93 - 0
code/ajyApp/uni_modules/uni-config-center/readme.md

@@ -0,0 +1,93 @@
+# 为什么使用uni-config-center
+
+实际开发中很多插件需要配置文件才可以正常运行,如果每个插件都单独进行配置的话就会产生下面这样的目录结构
+
+```bash
+cloudfunctions
+└─────common 公共模块
+        ├─plugin-a // 插件A对应的目录
+        │  ├─index.js
+        │  ├─config.json // plugin-a对应的配置文件
+        │  └─other-file.cert  // plugin-a依赖的其他文件
+        └─plugin-b // plugin-b对应的目录
+           ├─index.js
+           └─config.json // plugin-b对应的配置文件
+```
+
+假设插件作者要发布一个项目模板,里面使用了很多需要配置的插件,无论是作者发布还是用户使用都是一个大麻烦。
+
+uni-config-center就是用了统一管理这些配置文件的,使用uni-config-center后的目录结构如下
+
+```bash
+cloudfunctions
+└─────common 公共模块
+        ├─plugin-a // 插件A对应的目录
+        │  └─index.js
+        ├─plugin-b // plugin-b对应的目录
+        │  └─index.js
+        └─uni-config-center
+           ├─index.js // config-center入口文件
+           ├─plugin-a
+           │  ├─config.json  // plugin-a对应的配置文件
+           │  └─other-file.cert  // plugin-a依赖的其他文件
+           └─plugin-b
+              └─config.json  // plugin-b对应的配置文件
+```
+
+使用uni-config-center后的优势
+
+- 配置文件统一管理,分离插件主体和配置信息,更新插件更方便
+- 支持对config.json设置schema,插件使用者在HBuilderX内编写config.json文件时会有更好的提示(后续HBuilderX会提供支持)
+
+# 用法
+
+在要使用uni-config-center的公共模块或云函数内引入uni-config-center依赖,请参考:[使用公共模块](https://uniapp.dcloud.net.cn/uniCloud/cf-common)
+
+```js
+const createConfig = require('uni-config-center')
+
+const uniIdConfig = createConfig({
+    pluginId: 'uni-id', // 插件id
+    defaultConfig: { // 默认配置
+        tokenExpiresIn: 7200,
+        tokenExpiresThreshold: 600,
+    },
+    customMerge: function(defaultConfig, userConfig) { // 自定义默认配置和用户配置的合并规则,不设置的情况侠会对默认配置和用户配置进行深度合并
+        // defaudltConfig 默认配置
+        // userConfig 用户配置
+        return Object.assign(defaultConfig, userConfig)
+    }
+})
+
+
+// 以如下配置为例
+// {
+//   "tokenExpiresIn": 7200,
+//   "passwordErrorLimit": 6,
+//   "bindTokenToDevice": false,
+//   "passwordErrorRetryTime": 3600,
+//   "app-plus": {
+//     "tokenExpiresIn": 2592000
+//   },
+//   "service": {
+//     "sms": {
+//       "codeExpiresIn": 300
+//     }
+//   }
+// }
+
+// 获取配置
+uniIdConfig.config() // 获取全部配置,注意:uni-config-center内不存在对应插件目录时会返回空对象
+uniIdConfig.config('tokenExpiresIn') // 指定键值获取配置,返回:7200
+uniIdConfig.config('service.sms.codeExpiresIn') // 指定键值获取配置,返回:300
+uniIdConfig.config('tokenExpiresThreshold', 600) // 指定键值获取配置,如果不存在则取传入的默认值,返回:600
+
+// 获取文件绝对路径
+uniIdConfig.resolve('custom-token.js') // 获取uni-config-center/uni-id/custom-token.js文件的路径
+
+// 引用文件(require)
+uniIDConfig.requireFile('custom-token.js') // 使用require方式引用uni-config-center/uni-id/custom-token.js文件。文件不存在时返回undefined,文件内有其他错误导致require失败时会抛出错误。
+
+// 判断是否包含某文件
+uniIDConfig.hasFile('custom-token.js') // 配置目录是否包含某文件,true: 文件存在,false: 文件不存在
+```

File diff suppressed because it is too large
+ 0 - 0
code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/index.js


+ 13 - 0
code/ajyApp/uni_modules/uni-config-center/uniCloud/cloudfunctions/common/uni-config-center/package.json

@@ -0,0 +1,13 @@
+{
+    "name": "uni-config-center",
+    "version": "0.0.3",
+    "description": "配置中心",
+    "main": "index.js",
+    "keywords": [],
+    "author": "DCloud",
+    "license": "Apache-2.0",
+    "origin-plugin-dev-name": "uni-config-center",
+    "origin-plugin-version": "0.0.3",
+    "plugin-dev-name": "uni-config-center",
+    "plugin-version": "0.0.3"
+}

+ 38 - 0
code/ajyApp/uni_modules/uni-id-common/changelog.md

@@ -0,0 +1,38 @@
+## 1.0.19(2025-12-16)
+- 增加配置参数缺失时的错误提示,指明配置文件路径
+## 1.0.18(2024-07-08)
+- checkToken时如果传入的token为空则返回uni-id-check-token-failed错误码以便uniIdRouter能正常跳转
+## 1.0.17(2024-04-26)
+- 兼容uni-app-x对客户端uniPlatform的调整(uni-app-x内uniPlatform区分app-android、app-ios)
+## 1.0.16(2023-04-25)
+- 新增maxTokenLength配置,用于限制数据库用户记录token数组的最大长度
+## 1.0.15(2023-04-06)
+- 修复部分语言国际化出错的Bug
+## 1.0.14(2023-03-07)
+- 修复 admin用户包含其他角色时未包含在token的Bug
+## 1.0.13(2022-07-21)
+- 修复 创建token时未传角色权限信息生成的token不正确的bug
+## 1.0.12(2022-07-15)
+- 提升与旧版本uni-id的兼容性(补充读取配置文件时回退平台app-plus、h5),但是仍推荐使用新平台名进行配置(app、web)
+## 1.0.11(2022-07-14)
+- 修复 部分情况下报`read property 'reduce' of undefined`的错误
+## 1.0.10(2022-07-11)
+- 将token存储在用户表的token字段内,与旧版本uni-id保持一致
+## 1.0.9(2022-07-01)
+- checkToken兼容token内未缓存角色权限的情况,此时将查库获取角色权限
+## 1.0.8(2022-07-01)
+- 修复clientDB默认依赖时部分情况下获取不到uni-id配置的Bug
+## 1.0.7(2022-06-30)
+- 修复config文件不合法时未抛出具体错误的Bug
+## 1.0.6(2022-06-28)
+- 移除插件内的数据表schema
+## 1.0.5(2022-06-27)
+- 修复使用多应用配置时报`Cannot read property 'appId' of undefined`的Bug
+## 1.0.4(2022-06-27)
+- 修复使用自定义token内容功能报错的Bug [详情](https://ask.dcloud.net.cn/question/147945)
+## 1.0.2(2022-06-23)
+- 对齐旧版本uni-id默认配置
+## 1.0.1(2022-06-22)
+- 补充对uni-config-center的依赖
+## 1.0.0(2022-06-21)
+- 提供uni-id token创建、校验、刷新接口,简化旧版uni-id公共模块

+ 101 - 0
code/ajyApp/uni_modules/uni-id-common/package.json

@@ -0,0 +1,101 @@
+{
+  "id": "uni-id-common",
+  "displayName": "uni-id-common",
+  "version": "1.0.19",
+  "description": "包含uni-id token生成、校验、刷新功能的云函数公共模块",
+  "keywords": [
+    "uni-id-common",
+    "uniCloud",
+    "token",
+    "权限"
+],
+  "repository": "https://gitcode.net/dcloud/uni-id-common",
+  "engines": {
+    "uni-app": "^3.1.0",
+    "uni-app-x": "^3.1.0"
+  },
+  "dcloudext": {
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "无",
+      "permissions": "无"
+    },
+    "npmurl": "",
+    "type": "unicloud-template-function",
+    "darkmode": "-",
+    "i18n": "-",
+    "widescreen": "-"
+  },
+  "uni_modules": {
+    "dependencies": [
+      "uni-config-center"
+    ],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "√",
+        "aliyun": "√",
+        "alipay": "√"
+      },
+      "client": {
+        "uni-app": {
+          "vue": {
+            "vue2": "-",
+            "vue3": "-"
+          },
+          "web": {
+            "safari": "-",
+            "chrome": "-"
+          },
+          "app": {
+            "vue": "-",
+            "nvue": "-",
+            "android": "-",
+            "ios": "-",
+            "harmony": "-"
+          },
+          "mp": {
+            "weixin": "-",
+            "alipay": "-",
+            "toutiao": "-",
+            "baidu": "-",
+            "kuaishou": "-",
+            "jd": "-",
+            "harmony": "-",
+            "qq": "-",
+            "lark": "-"
+          },
+          "quickapp": {
+            "huawei": "-",
+            "union": "-"
+          }
+        },
+        "uni-app-x": {
+          "web": {
+            "safari": "-",
+            "chrome": "-"
+          },
+          "app": {
+            "android": "-",
+            "ios": "-",
+            "harmony": "-"
+          },
+          "mp": {
+            "weixin": "-"
+          }
+        }
+      }
+    }
+  }
+}

+ 3 - 0
code/ajyApp/uni_modules/uni-id-common/readme.md

@@ -0,0 +1,3 @@
+# uni-id-common
+
+文档请参考:[uni-id-common](https://uniapp.dcloud.net.cn/uniCloud/uni-id-common.html)

File diff suppressed because it is too large
+ 0 - 0
code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/index.js


+ 20 - 0
code/ajyApp/uni_modules/uni-id-common/uniCloud/cloudfunctions/common/uni-id-common/package.json

@@ -0,0 +1,20 @@
+{
+    "name": "uni-id-common",
+    "version": "1.0.19",
+    "description": "uni-id token生成、校验、刷新",
+    "main": "index.js",
+    "homepage": "https:\/\/uniapp.dcloud.io\/uniCloud\/uni-id-common.html",
+    "repository": {
+        "type": "git",
+        "url": "git+https:\/\/gitee.com\/dcloud\/uni-id-common.git"
+    },
+    "author": "DCloud",
+    "license": "Apache-2.0",
+    "dependencies": {
+        "uni-config-center": "file:..\/..\/..\/..\/..\/uni-config-center\/uniCloud\/cloudfunctions\/common\/uni-config-center"
+    },
+    "origin-plugin-dev-name": "uni-id-common",
+    "origin-plugin-version": "1.0.19",
+    "plugin-dev-name": "uni-id-common",
+    "plugin-version": "1.0.19"
+}

+ 7 - 0
code/backend/pom.xml

@@ -112,6 +112,13 @@
             <artifactId>dysmsapi20170525</artifactId>
             <version>2.0.24</version>
         </dependency>
+
+        <!-- 阿里云 OSS SDK(用于内容管理的图片/视频上传) -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+            <version>3.17.4</version>
+        </dependency>
     </dependencies>
 
     <build>

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/OssProperties.java

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.common.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 阿里云 OSS 配置属性
+ * 从 application.yml 中读取 aliyun.oss 前缀的配置
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "aliyun.oss")
+public class OssProperties {
+
+    /** OSS 服务的 Endpoint(例如:https://oss-cn-beijing.aliyuncs.com) */
+    private String endpoint;
+
+    /** AccessKey ID */
+    private String accessKeyId;
+
+    /** AccessKey Secret */
+    private String accessKeySecret;
+
+    /** 存储桶名称 */
+    private String bucketName;
+
+    /** 访问域名(用于拼接公网访问 URL) */
+    private String domain;
+
+    /** 文件在 OSS 中的根目录 */
+    private String baseDir = "content";
+
+    /** 图片最大大小(字节) */
+    private Long maxImageSize = 5242880L;
+
+    /** 视频最大大小(字节) */
+    private Long maxVideoSize = 52428800L;
+}

+ 21 - 1
code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java

@@ -111,7 +111,27 @@ public enum ResultCode {
     /** 不能禁用自身账号 */
     ADMIN_CANNOT_DISABLE_SELF(1802, "不能禁用当前登录账号"),
     /** 管理员角色不合法 */
-    ADMIN_ROLE_INVALID(1803, "管理员角色不合法");
+    ADMIN_ROLE_INVALID(1803, "管理员角色不合法"),
+
+    // ======================== 内容管理相关 1900-1949 ========================
+    /** 文章不存在 */
+    ARTICLE_NOT_FOUND(1900, "文章不存在"),
+    /** 文章状态不允许该操作 */
+    ARTICLE_STATUS_INVALID(1901, "当前状态下不允许该操作"),
+    /** 无权限操作文章 */
+    ARTICLE_PERMISSION_DENIED(1902, "没有权限执行该操作"),
+    /** 拒绝原因不能为空 */
+    ARTICLE_REJECT_REASON_REQUIRED(1903, "拒绝审核必须填写原因"),
+
+    // ======================== 文件上传相关 1950-1969 ========================
+    /** 文件为空 */
+    FILE_EMPTY(1950, "上传文件不能为空"),
+    /** 文件类型不支持 */
+    FILE_TYPE_NOT_SUPPORTED(1951, "文件类型不支持"),
+    /** 文件大小超限 */
+    FILE_SIZE_EXCEEDED(1952, "文件大小超过限制"),
+    /** 文件上传失败 */
+    FILE_UPLOAD_FAILED(1953, "文件上传失败");
 
     // ======================== 其他业务错误码在此区间之后继续添加 ========================
 

+ 20 - 2
code/backend/src/main/java/com/aijiuyi/admin/common/handler/MyMetaObjectHandler.java

@@ -1,5 +1,6 @@
 package com.aijiuyi.admin.common.handler;
 
+import com.aijiuyi.admin.common.context.RequestContext;
 import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
 import org.apache.ibatis.reflection.MetaObject;
 import org.springframework.stereotype.Component;
@@ -10,13 +11,14 @@ import java.time.LocalDateTime;
  * MyBatis Plus 自动填充处理器
  * 实体类字段使用 @TableField(fill = FieldFill.INSERT) 标注后,新增时自动填充 createTime
  * 使用 @TableField(fill = FieldFill.INSERT_UPDATE) 标注后,新增和更新时自动填充 updateTime
+ * 同时支持 createBy/updateBy 从当前登录用户自动填充
  */
 @Component
 public class MyMetaObjectHandler implements MetaObjectHandler {
 
     /**
      * 新增时自动填充
-     * 填充字段:createTime(创建时间)、updateTime(更新时间)
+     * 填充字段:createTime、updateTime、createBy、updateBy
      *
      * @param metaObject 元对象
      */
@@ -26,11 +28,22 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
         this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
         // 新增时也填充更新时间
         this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+        // 新增时自动填充创建人和更新人(从当前登录用户获取)
+        Long userId = RequestContext.getUserId();
+        if (userId != null) {
+            // 存在对应字段时才填充,防止影响其他不含该字段的实体
+            if (metaObject.hasSetter("createBy")) {
+                this.strictInsertFill(metaObject, "createBy", Long.class, userId);
+            }
+            if (metaObject.hasSetter("updateBy")) {
+                this.strictInsertFill(metaObject, "updateBy", Long.class, userId);
+            }
+        }
     }
 
     /**
      * 更新时自动填充
-     * 填充字段:updateTime(更新时间)
+     * 填充字段:updateTime、updateBy
      *
      * @param metaObject 元对象
      */
@@ -38,5 +51,10 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
     public void updateFill(MetaObject metaObject) {
         // 更新时自动更新时间
         this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+        // 更新时自动填充更新人
+        Long userId = RequestContext.getUserId();
+        if (userId != null && metaObject.hasSetter("updateBy")) {
+            this.strictUpdateFill(metaObject, "updateBy", Long.class, userId);
+        }
     }
 }

+ 147 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java

@@ -0,0 +1,147 @@
+package com.aijiuyi.admin.common.util;
+
+import com.aijiuyi.admin.common.config.OssProperties;
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.model.ObjectMetadata;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 阿里云 OSS 上传工具
+ * 负责上传文件到 OSS,返回公网可访问的 URL
+ */
+@Component
+public class OssUtil {
+
+    /** 允许的图片扩展名 */
+    private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp");
+
+    /** 允许的视频扩展名 */
+    private static final List<String> VIDEO_EXTENSIONS = Arrays.asList("mp4", "mov", "avi", "wmv", "flv", "mkv");
+
+    @Autowired
+    private OssProperties ossProperties;
+
+    /**
+     * 上传图片到 OSS
+     *
+     * @param file 上传文件
+     * @return 公网访问 URL
+     */
+    public String uploadImage(MultipartFile file) {
+        checkFile(file);
+        String ext = getExtension(file.getOriginalFilename());
+        if (!IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
+            throw new BusinessException(ResultCode.FILE_TYPE_NOT_SUPPORTED);
+        }
+        if (file.getSize() > ossProperties.getMaxImageSize()) {
+            throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED);
+        }
+        return doUpload(file, "image", ext);
+    }
+
+    /**
+     * 上传视频到 OSS
+     *
+     * @param file 上传文件
+     * @return 公网访问 URL
+     */
+    public String uploadVideo(MultipartFile file) {
+        checkFile(file);
+        String ext = getExtension(file.getOriginalFilename());
+        if (!VIDEO_EXTENSIONS.contains(ext.toLowerCase())) {
+            throw new BusinessException(ResultCode.FILE_TYPE_NOT_SUPPORTED);
+        }
+        if (file.getSize() > ossProperties.getMaxVideoSize()) {
+            throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED);
+        }
+        return doUpload(file, "video", ext);
+    }
+
+    /**
+     * 执行文件上传到 OSS
+     *
+     * @param file    文件对象
+     * @param subDir  子目录(image/video)
+     * @param ext     文件扩展名
+     * @return 公网访问 URL
+     */
+    private String doUpload(MultipartFile file, String subDir, String ext) {
+        // 构造 OSS 对象键:content/image/2026/05/12/uuid.jpg
+        String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        String objectKey = String.format("%s/%s/%s/%s.%s",
+                ossProperties.getBaseDir(), subDir, datePath,
+                UUID.randomUUID().toString().replace("-", ""), ext);
+
+        OSS ossClient = null;
+        try (InputStream inputStream = file.getInputStream()) {
+            ossClient = new OSSClientBuilder().build(
+                    ossProperties.getEndpoint(),
+                    ossProperties.getAccessKeyId(),
+                    ossProperties.getAccessKeySecret());
+            ObjectMetadata metadata = new ObjectMetadata();
+            metadata.setContentLength(file.getSize());
+            if (StringUtils.hasText(file.getContentType())) {
+                metadata.setContentType(file.getContentType());
+            }
+            ossClient.putObject(ossProperties.getBucketName(), objectKey, inputStream, metadata);
+            String url = ossProperties.getDomain() + "/" + objectKey;
+            LogUtil.info(OssUtil.class, "OSS 文件上传成功: {} ({} 字节)", url, file.getSize());
+            return url;
+        } catch (IOException e) {
+            LogUtil.error(OssUtil.class, "OSS 文件上传失败", e);
+            throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED);
+        } catch (Exception e) {
+            LogUtil.error(OssUtil.class, "OSS 服务异常", e);
+            throw new BusinessException(ResultCode.FILE_UPLOAD_FAILED);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 基础文件非空校验
+     *
+     * @param file 上传文件
+     */
+    private void checkFile(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException(ResultCode.FILE_EMPTY);
+        }
+        if (!StringUtils.hasText(file.getOriginalFilename())) {
+            throw new BusinessException(ResultCode.FILE_EMPTY);
+        }
+    }
+
+    /**
+     * 获取文件扩展名(不含点号,小写)
+     *
+     * @param fileName 文件名
+     * @return 扩展名
+     */
+    private String getExtension(String fileName) {
+        if (fileName == null) {
+            return "";
+        }
+        int idx = fileName.lastIndexOf('.');
+        if (idx < 0 || idx == fileName.length() - 1) {
+            return "";
+        }
+        return fileName.substring(idx + 1).toLowerCase();
+    }
+}

+ 157 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/ContentArticleController.java

@@ -0,0 +1,157 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
+import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.aijiuyi.admin.service.ContentArticleService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+/**
+ * 内容管理(文章)Controller
+ * 提供文章的增删改查、提交审核、发布、拒绝、上下架接口
+ */
+@RestController
+@RequestMapping("/content/articles")
+public class ContentArticleController {
+
+    @Autowired
+    private ContentArticleService articleService;
+
+    /**
+     * 分页查询文章列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping
+    @Log(value = "查询文章列表", module = "内容管理", operationType = OperationType.QUERY)
+    public Result<IPage<ContentArticle>> page(ContentArticleQueryDTO queryDTO) {
+        return Result.success(articleService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询文章详情
+     *
+     * @param id 文章ID
+     * @return 文章详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询文章详情", module = "内容管理", operationType = OperationType.QUERY)
+    public Result<ContentArticle> getById(@PathVariable Long id) {
+        return Result.success(articleService.getById(id));
+    }
+
+    /**
+     * 新建文章(初始状态为草稿)
+     *
+     * @param article 文章内容
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新建文章", module = "内容管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody ContentArticle article) {
+        articleService.addArticle(article);
+        return Result.success();
+    }
+
+    /**
+     * 编辑文章
+     *
+     * @param id      文章ID
+     * @param article 文章内容
+     * @return 操作结果
+     */
+    @PutMapping("/{id}")
+    @Log(value = "编辑文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@PathVariable Long id, @RequestBody ContentArticle article) {
+        article.setId(id);
+        articleService.updateArticle(article);
+        return Result.success();
+    }
+
+    /**
+     * 删除文章(逻辑删除,仅系统管理员可操作)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除文章", module = "内容管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        articleService.deleteArticle(id);
+        return Result.success();
+    }
+
+    /**
+     * 提交审核(草稿/已拒绝 → 待审核)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/submit")
+    @Log(value = "提交文章审核", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> submit(@PathVariable Long id) {
+        articleService.submitForReview(id);
+        return Result.success();
+    }
+
+    /**
+     * 审核通过并发布(待审核 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/publish")
+    @Log(value = "发布文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> publish(@PathVariable Long id) {
+        articleService.publish(id);
+        return Result.success();
+    }
+
+    /**
+     * 拒绝审核(待审核 → 已拒绝,仅系统管理员)
+     *
+     * @param id         文章ID
+     * @param rejectDTO  拒绝原因
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/reject")
+    @Log(value = "拒绝文章审核", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> reject(@PathVariable Long id, @RequestBody @Valid ArticleRejectDTO rejectDTO) {
+        articleService.reject(id, rejectDTO);
+        return Result.success();
+    }
+
+    /**
+     * 下架(已发布 → 已下架,仅系统管理员)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/offline")
+    @Log(value = "下架文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> offline(@PathVariable Long id) {
+        articleService.offline(id);
+        return Result.success();
+    }
+
+    /**
+     * 重新上架(已下架 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     * @return 操作结果
+     */
+    @PostMapping("/{id}/online")
+    @Log(value = "上架文章", module = "内容管理", operationType = OperationType.UPDATE)
+    public Result<Void> online(@PathVariable Long id) {
+        articleService.online(id);
+        return Result.success();
+    }
+}

+ 65 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java

@@ -0,0 +1,65 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.common.util.OssUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 文件上传 Controller(阿里云 OSS)
+ * 用于内容管理富文本编辑器中的图片/视频上传
+ */
+@RestController
+@RequestMapping("/file")
+public class FileController {
+
+    @Autowired
+    private OssUtil ossUtil;
+
+    /**
+     * 上传图片到 OSS
+     *
+     * @param file 图片文件
+     * @return 公网访问 URL
+     */
+    @PostMapping("/image")
+    @Log(value = "上传图片", module = "文件管理", operationType = OperationType.OTHER)
+    public Result<Map<String, String>> uploadImage(@RequestParam("file") MultipartFile file) {
+        String url = ossUtil.uploadImage(file);
+        return Result.success(buildResult(url));
+    }
+
+    /**
+     * 上传视频到 OSS
+     *
+     * @param file 视频文件
+     * @return 公网访问 URL
+     */
+    @PostMapping("/video")
+    @Log(value = "上传视频", module = "文件管理", operationType = OperationType.OTHER)
+    public Result<Map<String, String>> uploadVideo(@RequestParam("file") MultipartFile file) {
+        String url = ossUtil.uploadVideo(file);
+        return Result.success(buildResult(url));
+    }
+
+    /**
+     * 构造返回结果(同时返回 url 字段和前端富文本编辑器常用的 data 结构)
+     *
+     * @param url OSS 公网访问 URL
+     * @return 返回数据
+     */
+    private Map<String, String> buildResult(String url) {
+        Map<String, String> data = new HashMap<>(2);
+        data.put("url", url);
+        return data;
+    }
+}

+ 16 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ArticleRejectDTO.java

@@ -0,0 +1,16 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 文章审核拒绝 DTO
+ */
+@Data
+public class ArticleRejectDTO {
+
+    /** 拒绝原因 */
+    @NotBlank(message = "拒绝原因不能为空")
+    private String reason;
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/ContentArticleQueryDTO.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 内容文章查询条件 DTO
+ */
+@Data
+public class ContentArticleQueryDTO {
+
+    /** 标题模糊搜索 */
+    private String title;
+
+    /** 作者模糊搜索 */
+    private String author;
+
+    /**
+     * 状态:DRAFT=草稿,PENDING=待审核,PUBLISHED=已发布,
+     * REJECTED=已拒绝,OFFLINE=已下架
+     */
+    private String status;
+
+    /** 创建人ID(用于只看自己的) */
+    private Long createBy;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 74 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/ContentArticle.java

@@ -0,0 +1,74 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 内容文章实体
+ * 对应数据库 content_article 表
+ * 状态流转:DRAFT(草稿)→ PENDING(待审核)→ PUBLISHED(已发布)
+ *          PENDING → REJECTED(已拒绝)
+ *          PUBLISHED → OFFLINE(已下架)→ PUBLISHED(重新上架)
+ */
+@Data
+@TableName("content_article")
+public class ContentArticle {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 文章标题 */
+    private String title;
+
+    /** 富文本内容(含HTML标签、图片) */
+    private String content;
+
+    /** 封面图URL */
+    private String coverImage;
+
+    /** 作者名称 */
+    private String author;
+
+    /** 文章摘要 */
+    private String summary;
+
+    /**
+     * 状态:DRAFT=草稿,PENDING=待审核,PUBLISHED=已发布,
+     * REJECTED=已拒绝,OFFLINE=已下架
+     */
+    private String status;
+
+    /** 拒绝原因(被拒绝时记录) */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private String rejectReason;
+
+    /** 浏览次数 */
+    private Integer viewCount;
+
+    /** 发布时间 */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private LocalDateTime publishTime;
+
+    /** 创建人ID(对应 app_user.id) */
+    @TableField(fill = FieldFill.INSERT)
+    private Long createBy;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新人ID(对应 app_user.id) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Long updateBy;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/ContentArticleMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 内容文章 Mapper 接口
+ */
+@Mapper
+public interface ContentArticleMapper extends BaseMapper<ContentArticle> {
+}

+ 78 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/ContentArticleService.java

@@ -0,0 +1,78 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
+import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+/**
+ * 内容文章 Service 接口
+ */
+public interface ContentArticleService extends IService<ContentArticle> {
+
+    /**
+     * 分页查询文章列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<ContentArticle> pageList(ContentArticleQueryDTO queryDTO);
+
+    /**
+     * 新建文章(初始状态为草稿)
+     *
+     * @param article 文章内容
+     */
+    void addArticle(ContentArticle article);
+
+    /**
+     * 编辑文章(仅草稿/已拒绝状态可编辑,系统管理员可编辑任意状态)
+     *
+     * @param article 文章内容
+     */
+    void updateArticle(ContentArticle article);
+
+    /**
+     * 逻辑删除文章(仅系统管理员可操作)
+     *
+     * @param id 文章ID
+     */
+    void deleteArticle(Long id);
+
+    /**
+     * 提交审核(草稿/已拒绝 → 待审核)
+     *
+     * @param id 文章ID
+     */
+    void submitForReview(Long id);
+
+    /**
+     * 审核通过并发布(待审核 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    void publish(Long id);
+
+    /**
+     * 拒绝审核(待审核 → 已拒绝,仅系统管理员)
+     *
+     * @param id         文章ID
+     * @param rejectDTO  拒绝原因
+     */
+    void reject(Long id, ArticleRejectDTO rejectDTO);
+
+    /**
+     * 下架(已发布 → 已下架,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    void offline(Long id);
+
+    /**
+     * 重新上架(已下架 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    void online(Long id);
+}

+ 280 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/ContentArticleServiceImpl.java

@@ -0,0 +1,280 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
+import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.entity.ContentArticle;
+import com.aijiuyi.admin.mapper.ContentArticleMapper;
+import com.aijiuyi.admin.service.AppUserService;
+import com.aijiuyi.admin.service.ContentArticleService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+
+/**
+ * 内容文章 Service 实现
+ * 权限模型:
+ *   - 普通管理员(adminRole=2/3):可创建、编辑自己的草稿/已拒绝文章,可提交审核
+ *   - 系统管理员(adminRole=1):可编辑任意文章,可审核/发布/下架/上架/物理删除
+ */
+@Service
+public class ContentArticleServiceImpl extends ServiceImpl<ContentArticleMapper, ContentArticle>
+        implements ContentArticleService {
+
+    /** 状态常量 */
+    public static final String STATUS_DRAFT = "DRAFT";
+    public static final String STATUS_PENDING = "PENDING";
+    public static final String STATUS_PUBLISHED = "PUBLISHED";
+    public static final String STATUS_REJECTED = "REJECTED";
+    public static final String STATUS_OFFLINE = "OFFLINE";
+
+    @Autowired
+    private AppUserService appUserService;
+
+    /**
+     * 分页查询文章列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @Override
+    public IPage<ContentArticle> pageList(ContentArticleQueryDTO queryDTO) {
+        Page<ContentArticle> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<ContentArticle> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StringUtils.hasText(queryDTO.getTitle()), ContentArticle::getTitle, queryDTO.getTitle())
+               .like(StringUtils.hasText(queryDTO.getAuthor()), ContentArticle::getAuthor, queryDTO.getAuthor())
+               .eq(StringUtils.hasText(queryDTO.getStatus()), ContentArticle::getStatus, queryDTO.getStatus())
+               .eq(queryDTO.getCreateBy() != null, ContentArticle::getCreateBy, queryDTO.getCreateBy())
+               .orderByDesc(ContentArticle::getCreateTime);
+        return page(page, wrapper);
+    }
+
+    /**
+     * 新建文章(初始状态为草稿)
+     *
+     * @param article 文章内容
+     */
+    @Override
+    public void addArticle(ContentArticle article) {
+        article.setId(null);
+        // 新建文章默认草稿状态
+        article.setStatus(STATUS_DRAFT);
+        article.setViewCount(0);
+        article.setRejectReason(null);
+        article.setPublishTime(null);
+        save(article);
+        LogUtil.info(ContentArticleServiceImpl.class, "新建文章[{}],标题:{}", article.getId(), article.getTitle());
+    }
+
+    /**
+     * 编辑文章(仅草稿/已拒绝/已下架状态可编辑;系统管理员可编辑任意非已发布状态)
+     *
+     * @param article 文章内容
+     */
+    @Override
+    public void updateArticle(ContentArticle article) {
+        ContentArticle exist = getExistArticle(article.getId());
+        checkUpdatePermission(exist);
+        // 不允许前端直接修改状态、拒绝原因、发布时间、浏览量
+        article.setStatus(null);
+        article.setRejectReason(null);
+        article.setPublishTime(null);
+        article.setViewCount(null);
+        updateById(article);
+        LogUtil.info(ContentArticleServiceImpl.class, "编辑文章[{}]", article.getId());
+    }
+
+    /**
+     * 逻辑删除文章(仅系统管理员可操作)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void deleteArticle(Long id) {
+        getExistArticle(id);
+        requireSuperAdmin();
+        removeById(id);
+        LogUtil.info(ContentArticleServiceImpl.class, "删除文章[{}]", id);
+    }
+
+    /**
+     * 提交审核(草稿/已拒绝 → 待审核)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void submitForReview(Long id) {
+        ContentArticle exist = getExistArticle(id);
+        // 只有作者本人或系统管理员可提交
+        if (!isSuperAdmin() && !exist.getCreateBy().equals(RequestContext.getUserId())) {
+            throw new BusinessException(ResultCode.ARTICLE_PERMISSION_DENIED);
+        }
+        if (!STATUS_DRAFT.equals(exist.getStatus()) && !STATUS_REJECTED.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_PENDING);
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]提交审核", id);
+    }
+
+    /**
+     * 审核通过并发布(待审核 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void publish(Long id) {
+        requireSuperAdmin();
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_PENDING.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_PUBLISHED);
+        update.setPublishTime(LocalDateTime.now());
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]审核通过并发布", id);
+    }
+
+    /**
+     * 拒绝审核(待审核 → 已拒绝,仅系统管理员)
+     *
+     * @param id        文章ID
+     * @param rejectDTO 拒绝原因
+     */
+    @Override
+    public void reject(Long id, ArticleRejectDTO rejectDTO) {
+        requireSuperAdmin();
+        if (rejectDTO == null || !StringUtils.hasText(rejectDTO.getReason())) {
+            throw new BusinessException(ResultCode.ARTICLE_REJECT_REASON_REQUIRED);
+        }
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_PENDING.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_REJECTED);
+        update.setRejectReason(rejectDTO.getReason());
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]审核拒绝,原因:{}", id, rejectDTO.getReason());
+    }
+
+    /**
+     * 下架(已发布 → 已下架,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void offline(Long id) {
+        requireSuperAdmin();
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_PUBLISHED.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_OFFLINE);
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]下架", id);
+    }
+
+    /**
+     * 重新上架(已下架 → 已发布,仅系统管理员)
+     *
+     * @param id 文章ID
+     */
+    @Override
+    public void online(Long id) {
+        requireSuperAdmin();
+        ContentArticle exist = getExistArticle(id);
+        if (!STATUS_OFFLINE.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setStatus(STATUS_PUBLISHED);
+        // 首次发布时间已存在则保留,否则补发当前时间
+        if (exist.getPublishTime() == null) {
+            update.setPublishTime(LocalDateTime.now());
+        }
+        updateById(update);
+        LogUtil.info(ContentArticleServiceImpl.class, "文章[{}]重新上架", id);
+    }
+
+    /**
+     * 查询文章,不存在则抛出异常
+     *
+     * @param id 文章ID
+     * @return 文章对象
+     */
+    private ContentArticle getExistArticle(Long id) {
+        ContentArticle exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException(ResultCode.ARTICLE_NOT_FOUND);
+        }
+        return exist;
+    }
+
+    /**
+     * 校验编辑权限:
+     *   - 系统管理员可编辑任意非已发布状态
+     *   - 普通管理员仅可编辑自己的草稿/已拒绝文章
+     *
+     * @param exist 现有文章
+     */
+    private void checkUpdatePermission(ContentArticle exist) {
+        boolean superAdmin = isSuperAdmin();
+        if (superAdmin) {
+            // 已发布的文章必须先下架才能编辑
+            if (STATUS_PUBLISHED.equals(exist.getStatus())) {
+                throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+            }
+            return;
+        }
+        // 普通管理员仅可编辑自己的文章
+        Long userId = RequestContext.getUserId();
+        if (userId == null || !userId.equals(exist.getCreateBy())) {
+            throw new BusinessException(ResultCode.ARTICLE_PERMISSION_DENIED);
+        }
+        if (!STATUS_DRAFT.equals(exist.getStatus()) && !STATUS_REJECTED.equals(exist.getStatus())) {
+            throw new BusinessException(ResultCode.ARTICLE_STATUS_INVALID);
+        }
+    }
+
+    /**
+     * 要求当前用户必须为系统管理员(adminRole=1)
+     */
+    private void requireSuperAdmin() {
+        if (!isSuperAdmin()) {
+            throw new BusinessException(ResultCode.ARTICLE_PERMISSION_DENIED);
+        }
+    }
+
+    /**
+     * 判断当前登录用户是否为系统管理员(adminRole=1)
+     *
+     * @return true=系统管理员
+     */
+    private boolean isSuperAdmin() {
+        Long userId = RequestContext.getUserId();
+        if (userId == null) {
+            return false;
+        }
+        AppUser user = appUserService.getById(userId);
+        return user != null && user.getAdminRole() != null && user.getAdminRole() == 1;
+    }
+}

+ 23 - 3
code/backend/src/main/resources/application-dev.yml

@@ -3,7 +3,7 @@ spring:
   datasource:
     url: jdbc:mysql://localhost:3306/aijiuyi_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
     username: root
-    password: jpDb123$%^
+    password: lilishop
     driver-class-name: com.mysql.cj.jdbc.Driver
     hikari:
       # 最大连接池大小
@@ -15,10 +15,10 @@ spring:
 
   # ==================== Redis 配置 ====================
   redis:
-    host: 39.107.191.207
+    host: 127.0.0.1
     port: 6379
     # 密码(无密码留空,有密码填写)
-    password: bwy123$%^
+    password: lilishop
     database: 0
     # 连接超时时间
     timeout: 10000ms
@@ -33,6 +33,26 @@ spring:
         # 最小空闲连接
         min-idle: 0
 
+# ==================== 阿里云 OSS 配置(内容管理图片/视频上传) ====================
+aliyun:
+  oss:
+    # OSS 服务的 Endpoint(华北2-北京)
+    endpoint: https://oss-cn-beijing.aliyuncs.com
+    # AccessKey ID
+    access-key-id: LTAI5t7NZAFYFAcmh68MvFLK
+    # AccessKey Secret
+    access-key-secret: hEs6C02BCKycfoq5AzHvFSb3KAMIyb
+    # 存储桶名称
+    bucket-name: ajy001
+    # 访问域名(用于拼接文件公网访问 URL)
+    domain: https://ajy001.oss-cn-beijing.aliyuncs.com
+    # 文件在 OSS 中的根目录
+    base-dir: content
+    # 图片最大大小(字节,默认 5MB)
+    max-image-size: 5242880
+    # 视频最大大小(字节,默认 50MB)
+    max-video-size: 52428800
+
 # ==================== 日志配置(开发环境) ====================
 logging:
   level:

+ 22 - 0
code/backend/src/main/resources/application-test.yml

@@ -33,6 +33,28 @@ spring:
         # 最小空闲连接
         min-idle: 0
 
+
+# ==================== 阿里云 OSS 配置(内容管理图片/视频上传) ====================
+aliyun:
+  oss:
+    # OSS 服务的 Endpoint(华北2-北京)
+    endpoint: https://oss-cn-beijing.aliyuncs.com
+    # AccessKey ID
+    access-key-id: LTAI5t7NZAFYFAcmh68MvFLK
+    # AccessKey Secret
+    access-key-secret: hEs6C02BCKycfoq5AzHvFSb3KAMIyb
+    # 存储桶名称
+    bucket-name: ajy001
+    # 访问域名(用于拼接文件公网访问 URL)
+    domain: https://ajy001.oss-cn-beijing.aliyuncs.com
+    # 文件在 OSS 中的根目录
+    base-dir: content
+    # 图片最大大小(字节,默认 5MB)
+    max-image-size: 5242880
+    # 视频最大大小(字节,默认 50MB)
+    max-video-size: 52428800
+
+
 # ==================== 日志配置(开发环境) ====================
 logging:
   level:

+ 6 - 0
code/backend/src/main/resources/application.yml

@@ -8,6 +8,12 @@ spring:
     time-zone: Asia/Shanghai
     default-property-inclusion: non_null
 
+  # 文件上传大小限制(内容管理视频上传用)
+  servlet:
+    multipart:
+      max-file-size: 100MB
+      max-request-size: 100MB
+
 server:
   port: 18888
   servlet:

+ 33 - 0
code/backend/src/main/resources/sql/migration_content_article.sql

@@ -0,0 +1,33 @@
+-- ==================== 内容管理模块建表 SQL ====================
+-- 执行数据库:aijiuyi_admin
+-- 作用:创建文章内容表(content_article),支持草稿/待审核/已发布/已拒绝/已下架状态流转
+-- 管理员在 app_user 表中,通过 admin_role 字段区分权限:1=超级管理员(可审核),2/3=普通管理员(可创建编辑)
+
+SET NAMES utf8mb4;
+
+-- ----------------------------
+-- 内容文章表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `content_article` (
+    `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `title`          VARCHAR(200)  NOT NULL COMMENT '文章标题',
+    `content`        LONGTEXT               COMMENT '富文本内容(含HTML标签、图片、视频)',
+    `cover_image`    VARCHAR(500)           COMMENT '封面图URL',
+    `author`         VARCHAR(50)            COMMENT '作者名称',
+    `summary`        VARCHAR(500)           COMMENT '文章摘要',
+    `status`         VARCHAR(20)   NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT=草稿,PENDING=待审核,PUBLISHED=已发布,REJECTED=已拒绝,OFFLINE=已下架',
+    `reject_reason`  VARCHAR(500)           COMMENT '审核拒绝原因',
+    `view_count`     INT           NOT NULL DEFAULT 0 COMMENT '浏览次数',
+    `publish_time`   DATETIME               COMMENT '发布时间',
+    `create_by`      BIGINT                 COMMENT '创建人ID(关联 app_user.id)',
+    `create_time`    DATETIME               COMMENT '创建时间',
+    `update_by`      BIGINT                 COMMENT '更新人ID(关联 app_user.id)',
+    `update_time`    DATETIME               COMMENT '更新时间',
+    `deleted`        TINYINT       NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    PRIMARY KEY (`id`),
+    KEY `idx_status` (`status`),
+    KEY `idx_create_by` (`create_by`),
+    KEY `idx_create_time` (`create_time`)
+) ENGINE = InnoDB
+  DEFAULT CHARSET = utf8mb4
+  COMMENT = '内容文章表';

+ 537 - 0
code/frontend/package-lock.json

@@ -9,6 +9,8 @@
       "version": "0.0.0",
       "dependencies": {
         "@element-plus/icons-vue": "^2.3.2",
+        "@wangeditor/editor": "^5.1.23",
+        "@wangeditor/editor-for-vue": "^5.1.12",
         "axios": "^1.14.0",
         "element-plus": "^2.13.6",
         "pinia": "^3.0.4",
@@ -55,6 +57,15 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/types": {
       "version": "7.29.0",
       "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -751,6 +762,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@transloadit/prettier-bytes": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
+      "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
+      "license": "MIT"
+    },
     "node_modules/@tybys/wasm-util": {
       "version": "0.10.1",
       "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -762,6 +779,12 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@types/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
+      "license": "MIT"
+    },
     "node_modules/@types/lodash": {
       "version": "4.17.24",
       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
@@ -783,6 +806,61 @@
       "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
       "license": "MIT"
     },
+    "node_modules/@uppy/companion-client": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-2.2.2.tgz",
+      "integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/utils": "^4.1.2",
+        "namespace-emitter": "^2.0.1"
+      }
+    },
+    "node_modules/@uppy/core": {
+      "version": "2.3.4",
+      "resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
+      "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@transloadit/prettier-bytes": "0.0.7",
+        "@uppy/store-default": "^2.1.1",
+        "@uppy/utils": "^4.1.3",
+        "lodash.throttle": "^4.1.1",
+        "mime-match": "^1.0.2",
+        "namespace-emitter": "^2.0.1",
+        "nanoid": "^3.1.25",
+        "preact": "^10.5.13"
+      }
+    },
+    "node_modules/@uppy/store-default": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-2.1.1.tgz",
+      "integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
+      "license": "MIT"
+    },
+    "node_modules/@uppy/utils": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-4.1.3.tgz",
+      "integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash.throttle": "^4.1.1"
+      }
+    },
+    "node_modules/@uppy/xhr-upload": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
+      "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/companion-client": "^2.2.2",
+        "@uppy/utils": "^4.1.2",
+        "nanoid": "^3.1.25"
+      },
+      "peerDependencies": {
+        "@uppy/core": "^2.3.3"
+      }
+    },
     "node_modules/@vitejs/plugin-vue": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
@@ -969,6 +1047,165 @@
         "url": "https://github.com/sponsors/antfu"
       }
     },
+    "node_modules/@wangeditor/basic-modules": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
+      "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-url": "^1.2.4"
+      },
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/code-highlight": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
+      "integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
+      "license": "MIT",
+      "dependencies": {
+        "prismjs": "^1.23.0"
+      },
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/core": {
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
+      "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/event-emitter": "^0.3.3",
+        "event-emitter": "^0.3.5",
+        "html-void-elements": "^2.0.0",
+        "i18next": "^20.4.0",
+        "scroll-into-view-if-needed": "^2.2.28",
+        "slate-history": "^0.66.0"
+      },
+      "peerDependencies": {
+        "@uppy/core": "^2.1.1",
+        "@uppy/xhr-upload": "^2.0.3",
+        "dom7": "^3.0.0",
+        "is-hotkey": "^0.2.0",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.foreach": "^4.5.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "lodash.toarray": "^4.4.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/editor": {
+      "version": "5.1.23",
+      "resolved": "https://registry.npmjs.org/@wangeditor/editor/-/editor-5.1.23.tgz",
+      "integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/core": "^2.1.1",
+        "@uppy/xhr-upload": "^2.0.3",
+        "@wangeditor/basic-modules": "^1.1.7",
+        "@wangeditor/code-highlight": "^1.0.3",
+        "@wangeditor/core": "^1.1.19",
+        "@wangeditor/list-module": "^1.0.5",
+        "@wangeditor/table-module": "^1.1.4",
+        "@wangeditor/upload-image-module": "^1.0.2",
+        "@wangeditor/video-module": "^1.1.4",
+        "dom7": "^3.0.0",
+        "is-hotkey": "^0.2.0",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.foreach": "^4.5.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "lodash.toarray": "^4.4.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/editor-for-vue": {
+      "version": "5.1.12",
+      "resolved": "https://registry.npmjs.org/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
+      "integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/editor": ">=5.1.0",
+        "vue": "^3.0.5"
+      }
+    },
+    "node_modules/@wangeditor/list-module": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@wangeditor/list-module/-/list-module-1.0.5.tgz",
+      "integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/table-module": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@wangeditor/table-module/-/table-module-1.1.4.tgz",
+      "integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/upload-image-module": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
+      "integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@uppy/core": "^2.0.3",
+        "@uppy/xhr-upload": "^2.0.3",
+        "@wangeditor/basic-modules": "1.x",
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.foreach": "^4.5.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/video-module": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@wangeditor/video-module/-/video-module-1.1.4.tgz",
+      "integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@uppy/core": "^2.1.4",
+        "@uppy/xhr-upload": "^2.0.7",
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
     "node_modules/adler-32": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
@@ -1072,6 +1309,12 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "license": "MIT"
+    },
     "node_modules/copy-anything": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -1105,6 +1348,19 @@
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "license": "MIT"
     },
+    "node_modules/d": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
+      "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+      "license": "ISC",
+      "dependencies": {
+        "es5-ext": "^0.10.64",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
     "node_modules/dayjs": {
       "version": "1.11.20",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
@@ -1130,6 +1386,15 @@
         "node": ">=8"
       }
     },
+    "node_modules/dom7": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
+      "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
+      "license": "MIT",
+      "dependencies": {
+        "ssr-window": "^3.0.0-alpha.1"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1227,12 +1492,86 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es5-ext": {
+      "version": "0.10.64",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
+      "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+      "hasInstallScript": true,
+      "license": "ISC",
+      "dependencies": {
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.3",
+        "esniff": "^2.0.1",
+        "next-tick": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+      "license": "MIT",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "node_modules/es6-symbol": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
+      "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+      "license": "ISC",
+      "dependencies": {
+        "d": "^1.0.2",
+        "ext": "^1.7.0"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
+    "node_modules/esniff": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
+      "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+      "license": "ISC",
+      "dependencies": {
+        "d": "^1.0.1",
+        "es5-ext": "^0.10.62",
+        "event-emitter": "^0.3.5",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
     "node_modules/estree-walker": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
       "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
       "license": "MIT"
     },
+    "node_modules/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+      "license": "MIT",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
+    "node_modules/ext": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+      "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+      "license": "ISC",
+      "dependencies": {
+        "type": "^2.7.2"
+      }
+    },
     "node_modules/fdir": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1414,6 +1753,35 @@
       "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
       "license": "MIT"
     },
+    "node_modules/html-void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
+      "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/i18next": {
+      "version": "20.6.1",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
+      "integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.0"
+      }
+    },
+    "node_modules/immer": {
+      "version": "9.0.21",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+      "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
     "node_modules/immutable": {
       "version": "5.1.5",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -1443,6 +1811,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-hotkey": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
+      "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
+      "license": "MIT"
+    },
+    "node_modules/is-plain-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-url": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+      "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
+      "license": "MIT"
+    },
     "node_modules/is-what": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -1739,6 +2128,49 @@
         "lodash-es": "*"
       }
     },
+    "node_modules/lodash.camelcase": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.debounce": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.foreach": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
+      "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+      "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+      "license": "MIT"
+    },
+    "node_modules/lodash.throttle": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+      "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.toarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
+      "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
+      "license": "MIT"
+    },
     "node_modules/magic-string": {
       "version": "0.30.21",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1772,6 +2204,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/mime-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
+      "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
+      "license": "ISC",
+      "dependencies": {
+        "wildcard": "^1.1.0"
+      }
+    },
     "node_modules/mime-types": {
       "version": "2.1.35",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
@@ -1790,6 +2231,12 @@
       "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
       "license": "MIT"
     },
+    "node_modules/namespace-emitter": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
+      "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
+      "license": "MIT"
+    },
     "node_modules/nanoid": {
       "version": "3.3.11",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1808,6 +2255,12 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/next-tick": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+      "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
+      "license": "ISC"
+    },
     "node_modules/node-addon-api": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -1895,6 +2348,25 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/preact": {
+      "version": "10.29.1",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
+      "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
+    "node_modules/prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/proxy-from-env": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -1984,6 +2456,47 @@
         "@parcel/watcher": "^2.4.1"
       }
     },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/slate": {
+      "version": "0.72.8",
+      "resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
+      "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
+      "license": "MIT",
+      "dependencies": {
+        "immer": "^9.0.6",
+        "is-plain-object": "^5.0.0",
+        "tiny-warning": "^1.0.3"
+      }
+    },
+    "node_modules/slate-history": {
+      "version": "0.66.0",
+      "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.66.0.tgz",
+      "integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
+      "license": "MIT",
+      "dependencies": {
+        "is-plain-object": "^5.0.0"
+      },
+      "peerDependencies": {
+        "slate": ">=0.65.3"
+      }
+    },
+    "node_modules/snabbdom": {
+      "version": "3.6.3",
+      "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
+      "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.17.0"
+      }
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2014,6 +2527,12 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/ssr-window": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-3.0.0.tgz",
+      "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
+      "license": "MIT"
+    },
     "node_modules/superjson": {
       "version": "2.2.6",
       "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -2026,6 +2545,12 @@
         "node": ">=16"
       }
     },
+    "node_modules/tiny-warning": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+      "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+      "license": "MIT"
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.16",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2051,6 +2576,12 @@
       "license": "0BSD",
       "optional": true
     },
+    "node_modules/type": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
+      "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
+      "license": "ISC"
+    },
     "node_modules/vite": {
       "version": "8.0.7",
       "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
@@ -2177,6 +2708,12 @@
       "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
       "license": "MIT"
     },
+    "node_modules/wildcard": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
+      "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
+      "license": "MIT"
+    },
     "node_modules/wmf": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",

+ 2 - 0
code/frontend/package.json

@@ -10,6 +10,8 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.2",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/editor-for-vue": "^5.1.12",
     "axios": "^1.14.0",
     "element-plus": "^2.13.6",
     "pinia": "^3.0.4",

+ 115 - 0
code/frontend/src/api/content.js

@@ -0,0 +1,115 @@
+import request from '@/utils/request'
+
+/**
+ * 内容管理模块 API
+ * 对应后端 ContentArticleController 和 FileController
+ */
+
+/**
+ * 分页查询文章列表
+ * @param {Object} params 查询参数:title/author/status/createBy/pageNum/pageSize
+ */
+export function getArticlePage(params) {
+  return request.get('/content/articles', { params })
+}
+
+/**
+ * 查询文章详情
+ * @param {number} id 文章ID
+ */
+export function getArticleById(id) {
+  return request.get(`/content/articles/${id}`)
+}
+
+/**
+ * 新建文章
+ * @param {Object} data 文章内容
+ */
+export function addArticle(data) {
+  return request.post('/content/articles', data)
+}
+
+/**
+ * 编辑文章
+ * @param {number} id 文章ID
+ * @param {Object} data 文章内容
+ */
+export function updateArticle(id, data) {
+  return request.put(`/content/articles/${id}`, data)
+}
+
+/**
+ * 删除文章(逻辑删除,仅系统管理员可操作)
+ * @param {number} id 文章ID
+ */
+export function deleteArticle(id) {
+  return request.delete(`/content/articles/${id}`)
+}
+
+/**
+ * 提交文章审核
+ * @param {number} id 文章ID
+ */
+export function submitArticle(id) {
+  return request.post(`/content/articles/${id}/submit`)
+}
+
+/**
+ * 审核通过并发布(仅系统管理员)
+ * @param {number} id 文章ID
+ */
+export function publishArticle(id) {
+  return request.post(`/content/articles/${id}/publish`)
+}
+
+/**
+ * 拒绝审核(仅系统管理员)
+ * @param {number} id 文章ID
+ * @param {string} reason 拒绝原因
+ */
+export function rejectArticle(id, reason) {
+  return request.post(`/content/articles/${id}/reject`, { reason })
+}
+
+/**
+ * 下架文章(仅系统管理员)
+ * @param {number} id 文章ID
+ */
+export function offlineArticle(id) {
+  return request.post(`/content/articles/${id}/offline`)
+}
+
+/**
+ * 重新上架文章(仅系统管理员)
+ * @param {number} id 文章ID
+ */
+export function onlineArticle(id) {
+  return request.post(`/content/articles/${id}/online`)
+}
+
+/**
+ * 上传图片到阿里云 OSS
+ * @param {File} file 图片文件
+ * @returns {Promise<{data:{url:string}}>}
+ */
+export function uploadImage(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request.post('/file/image', formData, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+/**
+ * 上传视频到阿里云 OSS
+ * @param {File} file 视频文件
+ * @returns {Promise<{data:{url:string}}>}
+ */
+export function uploadVideo(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request.post('/file/video', formData, {
+    headers: { 'Content-Type': 'multipart/form-data' },
+    timeout: 120000
+  })
+}

+ 3 - 0
code/frontend/src/api/index.js

@@ -31,3 +31,6 @@ export * as adminApi from './admin'
 
 // 方案模拟测试模块
 export * as simulationApi from './simulation'
+
+// 内容管理模块
+export * as contentApi from './content'

+ 136 - 0
code/frontend/src/components/RichEditor.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="rich-editor">
+    <Toolbar
+      :editor="editorRef"
+      :default-config="toolbarConfig"
+      :mode="mode"
+      class="rich-editor-toolbar"
+    />
+    <Editor
+      v-model="valueRef"
+      :default-config="editorConfig"
+      :mode="mode"
+      class="rich-editor-content"
+      :style="{ height: height }"
+      @on-created="handleCreated"
+      @on-change="handleChange"
+    />
+  </div>
+</template>
+
+<script setup>
+/**
+ * 富文本编辑器组件(基于 @wangeditor/editor)
+ * 特性:
+ * 1. 支持图片/视频上传到阿里云 OSS
+ * 2. 自动禁用 Base64 模式,强制使用 OSS 上传
+ * 3. 通过 v-model 绑定富文本 HTML 内容
+ */
+import { ref, shallowRef, onBeforeUnmount, watch } from 'vue'
+import '@wangeditor/editor/dist/css/style.css'
+import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+import { ElMessage } from 'element-plus'
+import { uploadImage, uploadVideo } from '@/api/content'
+
+const props = defineProps({
+  modelValue: { type: String, default: '' },
+  height: { type: String, default: '480px' },
+  mode: { type: String, default: 'default' }, // default | simple
+  placeholder: { type: String, default: '请输入文章内容...' }
+})
+const emit = defineEmits(['update:modelValue', 'change'])
+
+// 编辑器实例(shallowRef 避免响应式开销)
+const editorRef = shallowRef(null)
+// 双向绑定的 HTML 内容
+const valueRef = ref(props.modelValue || '')
+
+// 外部值变化时同步到编辑器
+watch(
+  () => props.modelValue,
+  (v) => {
+    if (v !== valueRef.value) {
+      valueRef.value = v || ''
+    }
+  }
+)
+
+// 工具栏配置(排除不需要的按钮)
+const toolbarConfig = {
+  excludeKeys: ['fullScreen']
+}
+
+// 编辑器配置
+const editorConfig = {
+  placeholder: props.placeholder,
+  MENU_CONF: {
+    // 自定义图片上传(使用后端 OSS 接口)
+    uploadImage: {
+      async customUpload(file, insertFn) {
+        try {
+          const res = await uploadImage(file)
+          const url = res?.data?.url
+          if (url) {
+            insertFn(url, file.name, url)
+          } else {
+            ElMessage.error('图片上传失败')
+          }
+        } catch (e) {
+          // 全局拦截器已经弹出错误,这里兜底
+        }
+      }
+    },
+    // 自定义视频上传
+    uploadVideo: {
+      async customUpload(file, insertFn) {
+        try {
+          const res = await uploadVideo(file)
+          const url = res?.data?.url
+          if (url) {
+            insertFn(url, url)
+          } else {
+            ElMessage.error('视频上传失败')
+          }
+        } catch (e) {
+          // 全局拦截器已经弹出错误
+        }
+      }
+    }
+  }
+}
+
+// 编辑器创建完成回调
+function handleCreated(editor) {
+  editorRef.value = editor
+}
+
+// 内容变化回调
+function handleChange(editor) {
+  const html = editor.getHtml()
+  emit('update:modelValue', html)
+  emit('change', html)
+}
+
+// 组件销毁时及时销毁编辑器实例,防止内存泄漏
+onBeforeUnmount(() => {
+  const editor = editorRef.value
+  if (editor == null) return
+  editor.destroy()
+})
+</script>
+
+<style lang="scss" scoped>
+.rich-editor {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  overflow: hidden;
+  z-index: 100;
+  background: #fff;
+}
+.rich-editor-toolbar {
+  border-bottom: 1px solid #dcdfe6;
+}
+.rich-editor-content {
+  overflow-y: hidden;
+}
+</style>

+ 6 - 0
code/frontend/src/router/index.js

@@ -88,6 +88,12 @@ const routes = [
         component: () => import('@/views/simulation/index.vue'),
         meta: { requiresAuth: true, title: '方案模拟测试', icon: 'DataAnalysis' }
       },
+      {
+        path: 'content',
+        name: 'Content',
+        component: () => import('@/views/content/index.vue'),
+        meta: { requiresAuth: true, title: '内容管理', icon: 'Document' }
+      },
       {
         path: 'system',
         name: 'System',

+ 607 - 0
code/frontend/src/views/content/index.vue

@@ -0,0 +1,607 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索区域 -->
+    <el-card class="search-card">
+      <el-form :model="queryForm" inline>
+        <el-form-item label="标题">
+          <el-input v-model="queryForm.title" placeholder="请输入文章标题" clearable style="width:200px" />
+        </el-form-item>
+        <el-form-item label="作者">
+          <el-input v-model="queryForm.author" placeholder="请输入作者" clearable style="width:160px" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="queryForm.status" placeholder="全部" clearable style="width:140px">
+            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="!isSuperAdmin">
+          <el-checkbox v-model="onlyMine">只看我的</el-checkbox>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
+          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 表格区域 -->
+    <el-card class="table-card">
+      <template #header>
+        <div class="card-header">
+          <span class="title">文章列表</span>
+          <div class="btn-group">
+            <el-button type="primary" :icon="Plus" @click="handleAdd">新建文章</el-button>
+          </div>
+        </div>
+      </template>
+
+      <el-table :data="tableData" v-loading="loading" border stripe>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="author" label="作者" width="120">
+          <template #default="{ row }">{{ row.author || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="coverImage" label="封面" width="80" align="center">
+          <template #default="{ row }">
+            <el-image
+              v-if="row.coverImage"
+              :src="row.coverImage"
+              :preview-src-list="[row.coverImage]"
+              fit="cover"
+              style="width: 48px; height: 48px; border-radius: 4px"
+              preview-teleported
+            />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="状态" width="110" align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row.status)" size="small">
+              {{ statusLabel(row.status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="viewCount" label="浏览量" width="90" align="center">
+          <template #default="{ row }">{{ row.viewCount || 0 }}</template>
+        </el-table-column>
+        <el-table-column prop="publishTime" label="发布时间" width="165">
+          <template #default="{ row }">{{ row.publishTime || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间" width="165" />
+        <el-table-column label="操作" width="320" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
+            <el-button
+              size="small"
+              type="warning"
+              link
+              :disabled="!canEdit(row)"
+              @click="handleEdit(row)"
+            >编辑</el-button>
+            <el-button
+              v-if="canSubmit(row)"
+              size="small"
+              type="success"
+              link
+              @click="handleSubmit(row)"
+            >提交审核</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'PENDING'"
+              size="small"
+              type="success"
+              link
+              @click="handlePublish(row)"
+            >审核通过</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'PENDING'"
+              size="small"
+              type="danger"
+              link
+              @click="handleReject(row)"
+            >拒绝</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'PUBLISHED'"
+              size="small"
+              type="warning"
+              link
+              @click="handleOffline(row)"
+            >下架</el-button>
+            <el-button
+              v-if="isSuperAdmin && row.status === 'OFFLINE'"
+              size="small"
+              type="success"
+              link
+              @click="handleOnline(row)"
+            >上架</el-button>
+            <el-button
+              v-if="isSuperAdmin"
+              size="small"
+              type="danger"
+              link
+              @click="handleDelete(row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="queryForm.pageNum"
+        v-model:page-size="queryForm.pageSize"
+        :page-sizes="[10, 20, 50]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @size-change="loadData"
+        @current-change="loadData"
+      />
+    </el-card>
+
+    <!-- 新增/编辑/查看弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="dialogTitle"
+      width="min(1100px, 92vw)"
+      top="6vh"
+      destroy-on-close
+      :close-on-click-modal="false"
+      @closed="resetForm"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="formRules"
+        label-width="100px"
+        :disabled="dialogMode === 'view'"
+      >
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="formData.title" maxlength="200" show-word-limit placeholder="请输入标题" />
+        </el-form-item>
+        <el-form-item label="作者">
+          <el-input v-model="formData.author" maxlength="50" placeholder="请输入作者名称" />
+        </el-form-item>
+        <el-form-item label="封面图">
+          <el-upload
+            :show-file-list="false"
+            :http-request="handleCoverUpload"
+            accept="image/*"
+            :disabled="dialogMode === 'view'"
+          >
+            <div class="cover-uploader">
+              <img v-if="formData.coverImage" :src="formData.coverImage" class="cover-preview" />
+              <el-icon v-else class="cover-icon"><Plus /></el-icon>
+            </div>
+          </el-upload>
+          <div class="form-tip">建议尺寸 16:9,支持 JPG/PNG/WEBP,单张不超过 5MB</div>
+        </el-form-item>
+        <el-form-item label="摘要">
+          <el-input
+            v-model="formData.summary"
+            type="textarea"
+            :rows="2"
+            maxlength="500"
+            show-word-limit
+            placeholder="请输入文章摘要(可选)"
+          />
+        </el-form-item>
+        <el-form-item label="内容" prop="content">
+          <RichEditor
+            v-if="dialogMode !== 'view'"
+            v-model="formData.content"
+            height="460px"
+          />
+          <!-- 查看模式:只读展示 HTML 内容 -->
+          <div v-else class="view-content" v-html="formData.content || '<i>(无内容)</i>'" />
+        </el-form-item>
+        <el-form-item v-if="dialogMode === 'view' && formData.rejectReason" label="拒绝原因">
+          <el-alert type="error" :closable="false" show-icon :title="formData.rejectReason" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">
+          {{ dialogMode === 'view' ? '关闭' : '取消' }}
+        </el-button>
+        <template v-if="dialogMode !== 'view'">
+          <el-button :loading="submitLoading" @click="handleSaveDraft">保存草稿</el-button>
+          <el-button type="primary" :loading="submitLoading" @click="handleSaveAndSubmit">
+            保存并提交审核
+          </el-button>
+        </template>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search, Refresh, Plus } from '@element-plus/icons-vue'
+import RichEditor from '@/components/RichEditor.vue'
+import { useUserStore } from '@/store/user'
+import {
+  getArticlePage,
+  getArticleById,
+  addArticle,
+  updateArticle,
+  deleteArticle,
+  submitArticle,
+  publishArticle,
+  rejectArticle,
+  offlineArticle,
+  onlineArticle,
+  uploadImage
+} from '@/api/content'
+
+const userStore = useUserStore()
+// 是否系统管理员(拥有全部权限)
+const isSuperAdmin = computed(() => userStore.adminRole === 1)
+
+// 查询表单
+const queryForm = reactive({
+  title: '',
+  author: '',
+  status: '',
+  createBy: null,
+  pageNum: 1,
+  pageSize: 10
+})
+const onlyMine = ref(false)
+const tableData = ref([])
+const total = ref(0)
+const loading = ref(false)
+
+// 状态选项
+const statusOptions = [
+  { value: 'DRAFT', label: '草稿' },
+  { value: 'PENDING', label: '待审核' },
+  { value: 'PUBLISHED', label: '已发布' },
+  { value: 'REJECTED', label: '已拒绝' },
+  { value: 'OFFLINE', label: '已下架' }
+]
+
+// 状态 label 映射
+function statusLabel(status) {
+  return statusOptions.find(i => i.value === status)?.label || status
+}
+// 状态 Tag 样式映射
+function statusTagType(status) {
+  switch (status) {
+    case 'DRAFT': return 'info'
+    case 'PENDING': return 'warning'
+    case 'PUBLISHED': return 'success'
+    case 'REJECTED': return 'danger'
+    case 'OFFLINE': return ''
+    default: return ''
+  }
+}
+
+// 弹窗状态
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const dialogMode = ref('add') // add | edit | view
+const submitLoading = ref(false)
+const formRef = ref(null)
+
+const defaultForm = () => ({
+  id: null,
+  title: '',
+  author: userStore.nickname || '',
+  coverImage: '',
+  summary: '',
+  content: '',
+  status: '',
+  rejectReason: ''
+})
+const formData = reactive(defaultForm())
+
+// 表单校验规则
+const formRules = {
+  title: [
+    { required: true, message: '请输入标题', trigger: 'blur' },
+    { max: 200, message: '标题最长200字符', trigger: 'blur' }
+  ],
+  content: [
+    { required: true, message: '请输入文章内容', trigger: 'change' }
+  ]
+}
+
+/**
+ * 加载列表
+ */
+async function loadData() {
+  loading.value = true
+  try {
+    queryForm.createBy = onlyMine.value ? (userStore.userInfo?.userId || null) : null
+    const res = await getArticlePage(queryForm)
+    tableData.value = res.data.records || []
+    total.value = res.data.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  queryForm.pageNum = 1
+  loadData()
+}
+
+function handleReset() {
+  queryForm.title = ''
+  queryForm.author = ''
+  queryForm.status = ''
+  onlyMine.value = false
+  queryForm.pageNum = 1
+  loadData()
+}
+
+/**
+ * 判断当前用户是否有编辑权限
+ */
+function canEdit(row) {
+  if (isSuperAdmin.value) {
+    // 已发布的文章不可直接编辑
+    return row.status !== 'PUBLISHED'
+  }
+  // 普通管理员仅可编辑自己的草稿/已拒绝
+  return (
+    row.createBy === userStore.userInfo?.userId &&
+    (row.status === 'DRAFT' || row.status === 'REJECTED')
+  )
+}
+
+/**
+ * 判断是否可提交审核
+ */
+function canSubmit(row) {
+  const isOwner = row.createBy === userStore.userInfo?.userId
+  if (!isOwner && !isSuperAdmin.value) return false
+  return row.status === 'DRAFT' || row.status === 'REJECTED'
+}
+
+/**
+ * 新建文章
+ */
+function handleAdd() {
+  Object.assign(formData, defaultForm())
+  dialogMode.value = 'add'
+  dialogTitle.value = '新建文章'
+  dialogVisible.value = true
+}
+
+/**
+ * 编辑文章
+ */
+async function handleEdit(row) {
+  const res = await getArticleById(row.id)
+  Object.assign(formData, defaultForm(), res.data || {})
+  dialogMode.value = 'edit'
+  dialogTitle.value = '编辑文章'
+  dialogVisible.value = true
+}
+
+/**
+ * 查看文章
+ */
+async function handleView(row) {
+  const res = await getArticleById(row.id)
+  Object.assign(formData, defaultForm(), res.data || {})
+  dialogMode.value = 'view'
+  dialogTitle.value = '查看文章'
+  dialogVisible.value = true
+}
+
+/**
+ * 保存为草稿
+ */
+async function handleSaveDraft() {
+  const ok = await formRef.value.validate().catch(() => false)
+  if (!ok) return
+  submitLoading.value = true
+  try {
+    if (formData.id) {
+      await updateArticle(formData.id, formData)
+    } else {
+      await addArticle(formData)
+    }
+    ElMessage.success('已保存为草稿')
+    dialogVisible.value = false
+    loadData()
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+/**
+ * 保存并提交审核
+ */
+async function handleSaveAndSubmit() {
+  const ok = await formRef.value.validate().catch(() => false)
+  if (!ok) return
+  submitLoading.value = true
+  try {
+    let articleId = formData.id
+    if (articleId) {
+      await updateArticle(articleId, formData)
+    } else {
+      const res = await addArticle(formData)
+      // 如果后端返回了新增ID则使用,否则重新查询当前用户最新文章较麻烦;
+      // 改为前端不需要ID,直接在列表中再操作
+      articleId = res?.data?.id || null
+    }
+    if (articleId) {
+      await submitArticle(articleId)
+    }
+    ElMessage.success(articleId ? '已提交审核' : '已保存草稿,请在列表中提交审核')
+    dialogVisible.value = false
+    loadData()
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+/**
+ * 提交审核
+ */
+async function handleSubmit(row) {
+  await ElMessageBox.confirm(`确认提交文章「${row.title}」进行审核?`, '提示', { type: 'warning' })
+  await submitArticle(row.id)
+  ElMessage.success('已提交审核')
+  loadData()
+}
+
+/**
+ * 审核通过并发布
+ */
+async function handlePublish(row) {
+  await ElMessageBox.confirm(`确认审核通过并发布文章「${row.title}」?`, '提示', { type: 'success' })
+  await publishArticle(row.id)
+  ElMessage.success('文章已发布')
+  loadData()
+}
+
+/**
+ * 拒绝审核
+ */
+async function handleReject(row) {
+  const { value: reason } = await ElMessageBox.prompt('请输入拒绝原因', '审核拒绝', {
+    confirmButtonText: '确认拒绝',
+    cancelButtonText: '取消',
+    inputType: 'textarea',
+    inputPlaceholder: '请简要说明拒绝原因(必填)',
+    inputValidator: v => (v && v.trim() ? true : '拒绝原因不能为空')
+  })
+  await rejectArticle(row.id, reason.trim())
+  ElMessage.success('已拒绝审核')
+  loadData()
+}
+
+/**
+ * 下架
+ */
+async function handleOffline(row) {
+  await ElMessageBox.confirm(`确认下架文章「${row.title}」?`, '提示', { type: 'warning' })
+  await offlineArticle(row.id)
+  ElMessage.success('文章已下架')
+  loadData()
+}
+
+/**
+ * 重新上架
+ */
+async function handleOnline(row) {
+  await ElMessageBox.confirm(`确认重新上架文章「${row.title}」?`, '提示', { type: 'success' })
+  await onlineArticle(row.id)
+  ElMessage.success('文章已上架')
+  loadData()
+}
+
+/**
+ * 删除(逻辑删除)
+ */
+async function handleDelete(row) {
+  await ElMessageBox.confirm(`确认删除文章「${row.title}」?删除后不可恢复`, '危险操作', {
+    type: 'error',
+    confirmButtonText: '确认删除',
+    cancelButtonText: '取消'
+  })
+  await deleteArticle(row.id)
+  ElMessage.success('已删除')
+  loadData()
+}
+
+/**
+ * 封面图上传(自定义 http-request)
+ */
+async function handleCoverUpload({ file }) {
+  try {
+    const res = await uploadImage(file)
+    if (res?.data?.url) {
+      formData.coverImage = res.data.url
+      ElMessage.success('封面上传成功')
+    }
+  } catch (e) {
+    // 拦截器已提示
+  }
+}
+
+/**
+ * 关闭弹窗时重置表单
+ */
+function resetForm() {
+  Object.assign(formData, defaultForm())
+  formRef.value?.resetFields?.()
+}
+
+onMounted(() => {
+  loadData()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+.search-card {
+  :deep(.el-form-item) {
+    margin-bottom: 0;
+  }
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  .title {
+    font-size: 15px;
+    font-weight: 600;
+  }
+}
+.pagination {
+  margin-top: 16px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.cover-uploader {
+  width: 148px;
+  height: 88px;
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: border-color 0.2s;
+  overflow: hidden;
+  &:hover {
+    border-color: #409eff;
+  }
+  .cover-preview {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+  .cover-icon {
+    font-size: 28px;
+    color: #8c939d;
+  }
+}
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+  line-height: 1.4;
+}
+.view-content {
+  width: 100%;
+  padding: 8px 12px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  min-height: 200px;
+  max-height: 500px;
+  overflow-y: auto;
+  background: #fafafa;
+  :deep(img) { max-width: 100%; }
+  :deep(video) { max-width: 100%; }
+}
+</style>

+ 111 - 0
code/内容管理.md

@@ -0,0 +1,111 @@
+## 十二、内容管理模块设计
+
+### 12.1 功能概述
+
+管理员在后台创建/编辑文章内容(富文本+图片+视频),提交审核后由系统管理员审批,审批通过后发布到APP展示。
+
+### 12.2 角色与权限
+
+| 角色 | 权限 | 说明 |
+|------|------|------|
+| 普通管理员 | 创建、编辑草稿,提交审核 | 可新建和修改文章,提交待审核 |
+| 系统管理员 | 审核通过/拒绝,发布/下架、物理删除 | 拥有最终审批权,可管理所有文章 |
+
+### 12.3 状态流转
+
+```
+草稿(DRAFT) → 待审核(PENDING) → 已发布(PUBLISHED)
+                ↓                     ↓
+             已拒绝(REJECTED)     已下架(OFFLINE)
+                ↓
+           物理删除(DELETED)
+```
+
+### 12.4 数据模型
+
+**文章表 (content_article)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| title | VARCHAR(200) | 文章标题 |
+| content | LONGTEXT | 富文本内容(含HTML标签、图片) |
+| cover_image | VARCHAR(500) | 封面图URL |
+| author | VARCHAR(50) | 作者 |
+| summary | VARCHAR(500) | 文章摘要 |
+| status | VARCHAR(20) | 状态 |
+| reject_reason | VARCHAR(500) | 拒绝原因 |
+| view_count | INT | 浏览次数 |
+| publish_time | DATETIME | 发布时间 |
+| create_by | BIGINT | 创建人ID |
+| create_time | DATETIME | 创建时间 |
+| update_by | BIGINT | 更新人ID |
+| update_time | DATETIME | 更新时间 |
+| deleted | TINYINT | 逻辑删除(0正常1删除) |
+
+**文章附件表 (content_attachment)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT | 主键 |
+| article_id | BIGINT | 关联文章ID |
+| file_type | VARCHAR(20) | 文件类型(IMAGE/VIDEO) |
+| file_url | VARCHAR(500) | 文件URL |
+| file_size | INT | 文件大小(字节) |
+| file_name | VARCHAR(200) | 原文件名 |
+| create_time | DATETIME | 上传时间 |
+
+### 12.5 API设计
+
+**后台管理接口**
+
+| 方法 | 路径 | 说明 | 权限 |
+|------|------|------|------|
+| POST | /api/content/articles | 新建文章 | 普通管理员+ |
+| PUT | /api/content/articles/{id} | 编辑文章 | 普通管理员+ |
+| DELETE | /api/content/articles/{id} | 删除文章 | 系统管理员 |
+| GET | /api/content/articles/{id} | 获取文章详情 | 普通管理员+ |
+| GET | /api/content/articles | 文章列表(分页) | 普通管理员+ |
+| POST | /api/content/articles/{id}/submit | 提交审核 | 普通管理员+ |
+| POST | /api/content/articles/{id}/publish | 发布文章 | 系统管理员 |
+| POST | /api/content/articles/{id}/reject | 拒绝审核 | 系统管理员 |
+| POST | /api/content/articles/{id}/offline | 下架文章 | 系统管理员 |
+| POST | /api/content/articles/{id}/online | 上架文章 | 系统管理员 |
+
+**APP端接口**
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | /api/app/articles | 文章列表(已发布的) |
+| GET | /api/app/articles/{id} | 文章详情 |
+
+### 12.6 文件存储
+
+- **当前**:本地服务器目录存储
+- **上云时**:修改配置为OSS/七牛云URL,无需改代码
+
+**文件限制**
+
+| 类型 | 格式 | 大小限制 |
+|------|------|----------|
+| 图片 | JPG, PNG, GIF | 可配置(建议5MB) |
+| 视频 | MP4 | 可配置(建议50MB) |
+### 管理端
+管理端
+管理端功能都要有、app前端先不用实现
+
+
+
+阿里云oss访问控制
+AccessKey ID
+LTAI5t7NZAFYFAcmh68MvFLK
+AccessKey Secret
+hEs6C02BCKycfoq5AzHvFSb3KAMIyb
+存储桶:ajy001
+地区:华北2北京
+
+
+后端在backend
+管理端在frontend
+文字编写用富文本组件、支持上传到oss,以及展示。不要有bug,存储逻辑都要实现好。权限管理也要实现好。
+管理员都在app_user表里面。sys_user表没用

Some files were not shown because too many files changed in this diff