diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm
index 3b94e9b..c77930b 100644
Binary files a/database/development.sqlite3-shm and b/database/development.sqlite3-shm differ
diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal
index bc8a314..a90505f 100644
Binary files a/database/development.sqlite3-wal and b/database/development.sqlite3-wal differ
diff --git a/src/controllers/Page/AuthPageController.js b/src/controllers/Page/AuthPageController.js
new file mode 100644
index 0000000..1fd68b0
--- /dev/null
+++ b/src/controllers/Page/AuthPageController.js
@@ -0,0 +1,136 @@
+import Router from "utils/router.js"
+import UserService from "services/userService.js"
+import svgCaptcha from "svg-captcha"
+import CommonError from "@/utils/error/CommonError"
+import { logger } from "@/logger.js"
+
+/**
+ * 认证相关页面控制器
+ * 负责处理登录、注册、验证码、登出等认证相关功能
+ */
+class AuthPageController {
+ constructor() {
+ this.userService = new UserService()
+ }
+
+ // 未授权报错页
+ async indexNoAuth(ctx) {
+ return await ctx.render("page/auth/no-auth", {})
+ }
+
+ // 登录页
+ async loginGet(ctx) {
+ if (ctx.session.user) {
+ ctx.status = 200
+ ctx.redirect("/?msg=用户已登录")
+ return
+ }
+ return await ctx.render("page/login/index", { site_title: "登录" })
+ }
+
+ // 处理登录请求
+ async loginPost(ctx) {
+ const { username, email, password } = ctx.request.body
+ const result = await this.userService.login({ username, email, password })
+ ctx.session.user = result.user
+ ctx.body = { success: true, message: "登录成功" }
+ }
+
+ // 获取验证码
+ async captchaGet(ctx) {
+ var captcha = svgCaptcha.create({
+ size: 4, // 个数
+ width: 100, // 宽
+ height: 30, // 高
+ fontSize: 38, // 字体大小
+ color: true, // 字体颜色是否多变
+ noise: 4, // 干扰线几条
+ })
+ // 记录验证码信息(文本+过期时间)
+ // 这里设置5分钟后过期
+ const expireTime = Date.now() + 5 * 60 * 1000
+ ctx.session.captcha = {
+ text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证
+ expireTime: expireTime,
+ }
+ ctx.type = "image/svg+xml"
+ ctx.body = captcha.data
+ }
+
+ // 注册页
+ async registerGet(ctx) {
+ if (ctx.session.user) {
+ return ctx.redirect("/?msg=用户已登录")
+ }
+ return await ctx.render("page/register/index", { site_title: "注册" })
+ }
+
+ // 处理注册请求
+ async registerPost(ctx) {
+ const { username, password, code } = ctx.request.body
+
+ // 检查Session中是否存在验证码
+ if (!ctx.session.captcha) {
+ throw new CommonError("验证码不存在,请重新获取")
+ }
+
+ const { text, expireTime } = ctx.session.captcha
+
+ // 检查是否过期
+ if (Date.now() > expireTime) {
+ // 过期后清除Session中的验证码
+ delete ctx.session.captcha
+ throw new CommonError("验证码已过期,请重新获取")
+ }
+
+ if (!code) {
+ throw new CommonError("请输入验证码")
+ }
+
+ if (code.toLowerCase() !== text) {
+ throw new CommonError("验证码错误")
+ }
+
+ delete ctx.session.captcha
+
+ await this.userService.register({ username, name: username, password, role: "user" })
+ return ctx.redirect("/login")
+ }
+
+ // 退出登录
+ async logout(ctx) {
+ ctx.status = 200
+ delete ctx.session.user
+ ctx.set("hx-redirect", "/")
+ }
+
+ /**
+ * 创建认证相关路由
+ * @returns {Router} 路由实例
+ */
+ static createRoutes() {
+ const controller = new AuthPageController()
+ const router = new Router({ auth: "try" })
+
+ // 未授权报错页
+ router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false })
+
+ // 登录相关
+ router.get("/login", controller.loginGet.bind(controller), { auth: "try" })
+ router.post("/login", controller.loginPost.bind(controller), { auth: false })
+
+ // 注册相关
+ router.get("/register", controller.registerGet.bind(controller), { auth: "try" })
+ router.post("/register", controller.registerPost.bind(controller), { auth: false })
+
+ // 验证码
+ router.get("/captcha", controller.captchaGet.bind(controller), { auth: false })
+
+ // 登出
+ router.post("/logout", controller.logout.bind(controller), { auth: true })
+
+ return router
+ }
+}
+
+export default AuthPageController
\ No newline at end of file
diff --git a/src/controllers/Page/BasePageController.js b/src/controllers/Page/BasePageController.js
new file mode 100644
index 0000000..411a431
--- /dev/null
+++ b/src/controllers/Page/BasePageController.js
@@ -0,0 +1,102 @@
+import Router from "utils/router.js"
+import ArticleService from "services/ArticleService.js"
+import { logger } from "@/logger.js"
+
+/**
+ * 基础页面控制器
+ * 负责处理首页、静态页面、联系表单等基础功能
+ */
+class BasePageController {
+ constructor() {
+ this.articleService = new ArticleService()
+ }
+
+ // 首页
+ async indexGet(ctx) {
+ const blogs = await this.articleService.getPublishedArticles()
+ return await ctx.render(
+ "page/index/index",
+ {
+ apiList: [
+ {
+ name: "随机图片",
+ desc: "随机图片,点击查看。
右键可复制链接",
+ url: "https://pic.xieyaxin.top/random.php",
+ },
+ ],
+ blogs: blogs.slice(0, 4),
+ },
+ { includeSite: true, includeUser: true }
+ )
+ }
+
+ // 处理联系表单提交
+ async contactPost(ctx) {
+ const { name, email, subject, message } = ctx.request.body
+
+ // 简单的表单验证
+ if (!name || !email || !subject || !message) {
+ ctx.status = 400
+ ctx.body = { success: false, message: "请填写所有必填字段" }
+ return
+ }
+
+ // 这里可以添加邮件发送逻辑或数据库存储逻辑
+ // 目前只是简单的成功响应
+ logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`)
+
+ ctx.body = {
+ success: true,
+ message: "感谢您的留言,我们会尽快回复您!",
+ }
+ }
+
+ /**
+ * 通用页面渲染方法
+ * @param {string} name - 模板名称
+ * @param {Object} data - 页面数据
+ * @returns {Function} 页面渲染函数
+ */
+ pageGet(name, data) {
+ return async ctx => {
+ return await ctx.render(
+ name,
+ {
+ ...(data || {}),
+ },
+ { includeSite: true, includeUser: true }
+ )
+ }
+ }
+
+ /**
+ * 创建基础页面相关路由
+ * @returns {Router} 路由实例
+ */
+ static createRoutes() {
+ const controller = new BasePageController()
+ const router = new Router({ auth: "try" })
+
+ // 首页
+ router.get("/", controller.indexGet.bind(controller), { auth: false })
+
+ // 静态页面
+ router.get("/about", controller.pageGet("page/about/index"), { auth: false })
+ router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false })
+ router.get("/privacy", controller.pageGet("page/extra/privacy"), { auth: false })
+ router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false })
+ router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false })
+ router.get("/help", controller.pageGet("page/extra/help"), { auth: false })
+ router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false })
+
+ // 需要登录的页面
+ router.get("/notice", controller.pageGet("page/notice/index"), { auth: true })
+
+ // 联系表单处理
+ router.post("/contact", controller.contactPost.bind(controller), { auth: false })
+
+ return router
+ }
+}
+
+export default BasePageController
\ No newline at end of file
diff --git a/src/controllers/Page/PageController.js b/src/controllers/Page/PageController.js
deleted file mode 100644
index bfffa90..0000000
--- a/src/controllers/Page/PageController.js
+++ /dev/null
@@ -1,481 +0,0 @@
-import Router from "utils/router.js"
-import UserService from "services/userService.js"
-import SiteConfigService from "services/SiteConfigService.js"
-import ArticleService from "services/ArticleService.js"
-import svgCaptcha from "svg-captcha"
-import formidable from "formidable"
-import fs from "fs/promises"
-import path from "path"
-import { fileURLToPath } from "url"
-import CommonError from "@/utils/error/CommonError"
-import { logger } from "@/logger.js"
-import { R } from "@/utils/helper"
-import imageThumbnail from "image-thumbnail"
-
-class PageController {
- constructor() {
- this.userService = new UserService()
- this.siteConfigService = new SiteConfigService()
- this.siteConfigService = new SiteConfigService()
- this.articleService = new ArticleService()
- }
-
- // 首页
- async indexGet(ctx) {
- const blogs = await this.articleService.getPublishedArticles()
- return await ctx.render(
- "page/index/index",
- {
- apiList: [
- {
- name: "随机图片",
- desc: "随机图片,点击查看。
右键可复制链接",
- url: "https://pic.xieyaxin.top/random.php",
- },
- ],
- blogs: blogs.slice(0, 4),
- },
- { includeSite: true, includeUser: true }
- )
- }
-
- // 未授权报错页
- async indexNoAuth(ctx) {
- return await ctx.render("page/auth/no-auth", {})
- }
-
- // 登录页
- async loginGet(ctx) {
- if (ctx.session.user) {
- ctx.status = 200
- ctx.redirect("/?msg=用户已登录")
- return
- }
- return await ctx.render("page/login/index", { site_title: "登录" })
- }
-
- // 处理登录请求
- async loginPost(ctx) {
- const { username, email, password } = ctx.request.body
- const result = await this.userService.login({ username, email, password })
- ctx.session.user = result.user
- ctx.body = { success: true, message: "登录成功" }
- }
-
- // 获取验证码
- async captchaGet(ctx) {
- var captcha = svgCaptcha.create({
- size: 4, // 个数
- width: 100, // 宽
- height: 30, // 高
- fontSize: 38, // 字体大小
- color: true, // 字体颜色是否多变
- noise: 4, // 干扰线几条
- })
- // 记录验证码信息(文本+过期时间)
- // 这里设置5分钟后过期
- const expireTime = Date.now() + 5 * 60 * 1000
- ctx.session.captcha = {
- text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证
- expireTime: expireTime,
- }
- ctx.type = "image/svg+xml"
- ctx.body = captcha.data
- }
-
- // 注册页
- async registerGet(ctx) {
- if (ctx.session.user) {
- return ctx.redirect("/?msg=用户已登录")
- }
- // TODO 多个
- return await ctx.render("page/register/index", { site_title: "注册" })
- }
-
- // 处理注册请求
- async registerPost(ctx) {
- const { username, password, code } = ctx.request.body
-
- // 检查Session中是否存在验证码
- if (!ctx.session.captcha) {
- throw new CommonError("验证码不存在,请重新获取")
- }
-
- const { text, expireTime } = ctx.session.captcha
-
- // 检查是否过期
- if (Date.now() > expireTime) {
- // 过期后清除Session中的验证码
- delete ctx.session.captcha
- throw new CommonError("验证码已过期,请重新获取")
- }
-
- if (!code) {
- throw new CommonError("请输入验证码")
- }
-
- if (code.toLowerCase() !== text) {
- throw new CommonError("验证码错误")
- }
-
- delete ctx.session.captcha
-
- await this.userService.register({ username, name: username, password, role: "user" })
- return ctx.redirect("/login")
- }
-
- // 退出登录
- async logout(ctx) {
- ctx.status = 200
- delete ctx.session.user
- ctx.set("hx-redirect", "/")
- }
-
- // 获取用户资料
- async profileGet(ctx) {
- if (!ctx.session.user) {
- return ctx.redirect("/login")
- }
-
- try {
- const user = await this.userService.getUserById(ctx.session.user.id)
- return await ctx.render(
- "page/profile/index",
- {
- user,
- site_title: "用户资料",
- },
- { includeSite: true, includeUser: true }
- )
- } catch (error) {
- logger.error(`获取用户资料失败: ${error.message}`)
- ctx.status = 500
- ctx.body = { success: false, message: "获取用户资料失败" }
- }
- }
-
- // 更新用户资料
- async profileUpdate(ctx) {
- if (!ctx.session.user) {
- ctx.status = 401
- ctx.body = { success: false, message: "未登录" }
- return
- }
-
- try {
- const { username, email, name, bio, avatar } = ctx.request.body
-
- // 验证必填字段
- if (!username) {
- ctx.status = 400
- ctx.body = { success: false, message: "用户名不能为空" }
- return
- }
-
- const updateData = { username, email, name, bio, avatar }
-
- // 移除空值
- Object.keys(updateData).forEach(key => {
- if (updateData[key] === undefined || updateData[key] === null || updateData[key] === "") {
- delete updateData[key]
- }
- })
-
- const updatedUser = await this.userService.updateUser(ctx.session.user.id, updateData)
-
- // 更新session中的用户信息
- ctx.session.user = { ...ctx.session.user, ...updatedUser }
-
- ctx.body = {
- success: true,
- message: "资料更新成功",
- user: updatedUser,
- }
- } catch (error) {
- logger.error(`更新用户资料失败: ${error.message}`)
- ctx.status = 500
- ctx.body = { success: false, message: error.message || "更新用户资料失败" }
- }
- }
-
- // 修改密码
- async changePassword(ctx) {
- if (!ctx.session.user) {
- ctx.status = 401
- ctx.body = { success: false, message: "未登录" }
- return
- }
-
- try {
- const { oldPassword, newPassword, confirmPassword } = ctx.request.body
-
- if (!oldPassword || !newPassword || !confirmPassword) {
- ctx.status = 400
- ctx.body = { success: false, message: "请填写所有密码字段" }
- return
- }
-
- if (newPassword !== confirmPassword) {
- ctx.status = 400
- ctx.body = { success: false, message: "新密码与确认密码不匹配" }
- return
- }
-
- if (newPassword.length < 6) {
- ctx.status = 400
- ctx.body = { success: false, message: "新密码长度不能少于6位" }
- return
- }
-
- await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword)
-
- ctx.body = {
- success: true,
- message: "密码修改成功",
- }
- } catch (error) {
- logger.error(`修改密码失败: ${error.message}`)
- ctx.status = 500
- ctx.body = { success: false, message: error.message || "修改密码失败" }
- }
- }
-
- // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组)
- async upload(ctx) {
- try {
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
- const publicDir = path.resolve(__dirname, "../../../public")
- const uploadsDir = path.resolve(publicDir, "uploads/files")
- // 确保目录存在
- await fs.mkdir(uploadsDir, { recursive: true })
-
- // 只需配置一个类型-扩展名映射数组
- const defaultTypeList = [
- { mime: "image/jpeg", ext: ".jpg" },
- { mime: "image/png", ext: ".png" },
- { mime: "image/webp", ext: ".webp" },
- { mime: "image/gif", ext: ".gif" },
- { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx
- { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls
- { mime: "application/msword", ext: ".doc" }, // .doc
- { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx
- ]
- let typeList = defaultTypeList
-
- // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型)
- if (ctx.query.allowedTypes) {
- const allowed = ctx.query.allowedTypes
- .split(",")
- .map(t => t.trim())
- .filter(Boolean)
- typeList = defaultTypeList.filter(item => allowed.includes(item.mime))
- }
-
- const allowedTypes = typeList.map(item => item.mime)
- const fallbackExt = ".bin"
-
- const form = formidable({
- multiples: true, // 支持多文件
- maxFileSize: 10 * 1024 * 1024, // 10MB
- filter: ({ mimetype }) => {
- return !!mimetype && allowedTypes.includes(mimetype)
- },
- uploadDir: uploadsDir,
- keepExtensions: true,
- })
-
- const { files } = await new Promise((resolve, reject) => {
- form.parse(ctx.req, (err, fields, files) => {
- if (err) return reject(err)
- resolve({ fields, files })
- })
- })
-
- let fileList = files.file
- if (!fileList) {
- return R.ResponseJSON(R.ERROR, null, "未选择文件或字段名应为 file")
- }
- // 统一为数组
- if (!Array.isArray(fileList)) {
- fileList = [fileList]
- }
-
- // 处理所有文件
- const urls = []
- for (const picked of fileList) {
- if (!picked) continue
- const oldPath = picked.filepath || picked.path
- // 优先用mimetype判断扩展名
- let ext = (typeList.find(item => item.mime === picked.mimetype) || {}).ext
- if (!ext) {
- // 回退到原始文件名的扩展名
- ext = path.extname(picked.originalFilename || picked.newFilename || "") || fallbackExt
- }
- // 文件名
- const filename = `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`
- const destPath = path.join(uploadsDir, filename)
- // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名
- if (oldPath && oldPath !== destPath) {
- await fs.rename(oldPath, destPath)
- }
- // 注意:此处url路径与public下的uploads/files对应
- const url = `/uploads/files/${filename}`
- urls.push(url)
- }
-
- ctx.body = {
- success: true,
- message: "上传成功",
- urls,
- }
- } catch (error) {
- logger.error(`上传失败: ${error.message}`)
- ctx.status = 500
- ctx.body = { success: false, message: error.message || "上传失败" }
- }
- }
-
- // 上传头像(multipart/form-data)
- async uploadAvatar(ctx) {
- try {
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
- const publicDir = path.resolve(__dirname, "../../../public")
- const avatarsDir = path.resolve(publicDir, "uploads/avatars")
-
- // 确保目录存在
- await fs.mkdir(avatarsDir, { recursive: true })
-
- const form = formidable({
- multiples: false,
- maxFileSize: 5 * 1024 * 1024, // 5MB
- filter: ({ mimetype }) => {
- return !!mimetype && /^(image\/jpeg|image\/png|image\/webp|image\/gif)$/.test(mimetype)
- },
- uploadDir: avatarsDir,
- keepExtensions: true,
- })
-
- const { files } = await new Promise((resolve, reject) => {
- form.parse(ctx.req, (err, fields, files) => {
- if (err) return reject(err)
- resolve({ fields, files })
- })
- })
-
- const file = files.avatar || files.file || files.image
- const picked = Array.isArray(file) ? file[0] : file
- if (!picked) {
- ctx.status = 400
- ctx.body = { success: false, message: "未选择文件或字段名应为 avatar" }
- return
- }
-
- // formidable v2 的文件对象
- const oldPath = picked.filepath || picked.path
- const result = { url: "", thumb: "" }
- const ext = path.extname(picked.originalFilename || picked.newFilename || "") || path.extname(oldPath || "") || ".jpg"
- const safeExt = [".jpg", ".jpeg", ".png", ".webp", ".gif"].includes(ext.toLowerCase()) ? ext : ".jpg"
- const filename = `${ctx.session.user.id}-${Date.now()}/raw${safeExt}`
- const destPath = path.join(avatarsDir, filename)
-
- // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名
- if (oldPath && oldPath !== destPath) {
- await fs.mkdir(path.parse(destPath).dir, { recursive: true })
- await fs.rename(oldPath, destPath)
- try {
- const thumbnail = await imageThumbnail(destPath)
- fs.writeFile(destPath.replace(/raw\./, "thumb."), thumbnail)
- } catch (err) {
- console.error(err)
- }
- }
-
- const url = `/uploads/avatars/${filename}`
- result.url = url
- result.thumb = url.replace(/raw\./, "thumb.")
- const updatedUser = await this.userService.updateUser(ctx.session.user.id, { avatar: url })
- ctx.session.user = { ...ctx.session.user, ...updatedUser }
-
- ctx.body = {
- success: true,
- message: "头像上传成功",
- url,
- thumb: result.thumb,
- user: updatedUser,
- }
- } catch (error) {
- logger.error(`上传头像失败: ${error.message}`)
- ctx.status = 500
- ctx.body = { success: false, message: error.message || "上传头像失败" }
- }
- }
-
- // 处理联系表单提交
- async contactPost(ctx) {
- const { name, email, subject, message } = ctx.request.body
-
- // 简单的表单验证
- if (!name || !email || !subject || !message) {
- ctx.status = 400
- ctx.body = { success: false, message: "请填写所有必填字段" }
- return
- }
-
- // 这里可以添加邮件发送逻辑或数据库存储逻辑
- // 目前只是简单的成功响应
- logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`)
-
- ctx.body = {
- success: true,
- message: "感谢您的留言,我们会尽快回复您!",
- }
- }
-
- // 渲染页面
- pageGet(name, data) {
- return async ctx => {
- return await ctx.render(
- name,
- {
- ...(data || {}),
- },
- { includeSite: true, includeUser: true }
- )
- }
- }
-
- static createRoutes() {
- const controller = new PageController()
- const router = new Router({ auth: "try" })
- // 首页
- router.get("/", controller.indexGet.bind(controller), { auth: false })
- // 未授权报错页
- router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false })
-
- // router.get("/article/:id", controller.pageGet("page/articles/index"), { auth: false })
- // router.get("/articles", controller.pageGet("page/articles/index"), { auth: false })
-
- router.get("/about", controller.pageGet("page/about/index"), { auth: false })
- router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false })
- router.get("/privacy", controller.pageGet("page/extra/privacy"), { auth: false })
- router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false })
- router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false })
- router.get("/profile", controller.profileGet.bind(controller), { auth: true })
- router.post("/profile/update", controller.profileUpdate.bind(controller), { auth: true })
- router.post("/profile/change-password", controller.changePassword.bind(controller), { auth: true })
- router.post("/profile/upload-avatar", controller.uploadAvatar.bind(controller), { auth: true })
- router.get("/notice", controller.pageGet("page/notice/index"), { auth: true })
- router.get("/help", controller.pageGet("page/extra/help"), { auth: false })
- router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false })
- router.post("/contact", controller.contactPost.bind(controller), { auth: false })
- router.get("/login", controller.loginGet.bind(controller), { auth: "try" })
- router.post("/login", controller.loginPost.bind(controller), { auth: false })
- router.get("/captcha", controller.captchaGet.bind(controller), { auth: false })
- router.get("/register", controller.registerGet.bind(controller), { auth: "try" })
- router.post("/register", controller.registerPost.bind(controller), { auth: false })
- router.post("/logout", controller.logout.bind(controller), { auth: true })
- return router
- }
-}
-
-export default PageController
diff --git a/src/controllers/Page/ProfileController.js b/src/controllers/Page/ProfileController.js
new file mode 100644
index 0000000..3a3678c
--- /dev/null
+++ b/src/controllers/Page/ProfileController.js
@@ -0,0 +1,228 @@
+import Router from "utils/router.js"
+import UserService from "services/userService.js"
+import formidable from "formidable"
+import fs from "fs/promises"
+import path from "path"
+import { fileURLToPath } from "url"
+import CommonError from "@/utils/error/CommonError"
+import { logger } from "@/logger.js"
+import imageThumbnail from "image-thumbnail"
+
+/**
+ * 用户资料控制器
+ * 负责处理用户资料管理、密码修改、头像上传等功能
+ */
+class ProfileController {
+ constructor() {
+ this.userService = new UserService()
+ }
+
+ // 获取用户资料
+ async profileGet(ctx) {
+ if (!ctx.session.user) {
+ return ctx.redirect("/login")
+ }
+
+ try {
+ const user = await this.userService.getUserById(ctx.session.user.id)
+ return await ctx.render(
+ "page/profile/index",
+ {
+ user,
+ site_title: "用户资料",
+ },
+ { includeSite: true, includeUser: true }
+ )
+ } catch (error) {
+ logger.error(`获取用户资料失败: ${error.message}`)
+ ctx.status = 500
+ ctx.body = { success: false, message: "获取用户资料失败" }
+ }
+ }
+
+ // 更新用户资料
+ async profileUpdate(ctx) {
+ if (!ctx.session.user) {
+ ctx.status = 401
+ ctx.body = { success: false, message: "未登录" }
+ return
+ }
+
+ try {
+ const { username, email, name, bio, avatar } = ctx.request.body
+
+ // 验证必填字段
+ if (!username) {
+ ctx.status = 400
+ ctx.body = { success: false, message: "用户名不能为空" }
+ return
+ }
+
+ const updateData = { username, email, name, bio, avatar }
+
+ // 移除空值
+ Object.keys(updateData).forEach(key => {
+ if (updateData[key] === undefined || updateData[key] === null || updateData[key] === "") {
+ delete updateData[key]
+ }
+ })
+
+ const updatedUser = await this.userService.updateUser(ctx.session.user.id, updateData)
+
+ // 更新session中的用户信息
+ ctx.session.user = { ...ctx.session.user, ...updatedUser }
+
+ ctx.body = {
+ success: true,
+ message: "资料更新成功",
+ user: updatedUser,
+ }
+ } catch (error) {
+ logger.error(`更新用户资料失败: ${error.message}`)
+ ctx.status = 500
+ ctx.body = { success: false, message: error.message || "更新用户资料失败" }
+ }
+ }
+
+ // 修改密码
+ async changePassword(ctx) {
+ if (!ctx.session.user) {
+ ctx.status = 401
+ ctx.body = { success: false, message: "未登录" }
+ return
+ }
+
+ try {
+ const { oldPassword, newPassword, confirmPassword } = ctx.request.body
+
+ if (!oldPassword || !newPassword || !confirmPassword) {
+ ctx.status = 400
+ ctx.body = { success: false, message: "请填写所有密码字段" }
+ return
+ }
+
+ if (newPassword !== confirmPassword) {
+ ctx.status = 400
+ ctx.body = { success: false, message: "新密码与确认密码不匹配" }
+ return
+ }
+
+ if (newPassword.length < 6) {
+ ctx.status = 400
+ ctx.body = { success: false, message: "新密码长度不能少于6位" }
+ return
+ }
+
+ await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword)
+
+ ctx.body = {
+ success: true,
+ message: "密码修改成功",
+ }
+ } catch (error) {
+ logger.error(`修改密码失败: ${error.message}`)
+ ctx.status = 500
+ ctx.body = { success: false, message: error.message || "修改密码失败" }
+ }
+ }
+
+ // 上传头像(multipart/form-data)
+ async uploadAvatar(ctx) {
+ try {
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
+ const publicDir = path.resolve(__dirname, "../../../public")
+ const avatarsDir = path.resolve(publicDir, "uploads/avatars")
+
+ // 确保目录存在
+ await fs.mkdir(avatarsDir, { recursive: true })
+
+ const form = formidable({
+ multiples: false,
+ maxFileSize: 5 * 1024 * 1024, // 5MB
+ filter: ({ mimetype }) => {
+ return !!mimetype && /^(image\/jpeg|image\/png|image\/webp|image\/gif)$/.test(mimetype)
+ },
+ uploadDir: avatarsDir,
+ keepExtensions: true,
+ })
+
+ const { files } = await new Promise((resolve, reject) => {
+ form.parse(ctx.req, (err, fields, files) => {
+ if (err) return reject(err)
+ resolve({ fields, files })
+ })
+ })
+
+ const file = files.avatar || files.file || files.image
+ const picked = Array.isArray(file) ? file[0] : file
+ if (!picked) {
+ ctx.status = 400
+ ctx.body = { success: false, message: "未选择文件或字段名应为 avatar" }
+ return
+ }
+
+ // formidable v2 的文件对象
+ const oldPath = picked.filepath || picked.path
+ const result = { url: "", thumb: "" }
+ const ext = path.extname(picked.originalFilename || picked.newFilename || "") || path.extname(oldPath || "") || ".jpg"
+ const safeExt = [".jpg", ".jpeg", ".png", ".webp", ".gif"].includes(ext.toLowerCase()) ? ext : ".jpg"
+ const filename = `${ctx.session.user.id}-${Date.now()}/raw${safeExt}`
+ const destPath = path.join(avatarsDir, filename)
+
+ // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名
+ if (oldPath && oldPath !== destPath) {
+ await fs.mkdir(path.parse(destPath).dir, { recursive: true })
+ await fs.rename(oldPath, destPath)
+ try {
+ const thumbnail = await imageThumbnail(destPath)
+ fs.writeFile(destPath.replace(/raw\./, "thumb."), thumbnail)
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ const url = `/uploads/avatars/${filename}`
+ result.url = url
+ result.thumb = url.replace(/raw\./, "thumb.")
+ const updatedUser = await this.userService.updateUser(ctx.session.user.id, { avatar: url })
+ ctx.session.user = { ...ctx.session.user, ...updatedUser }
+
+ ctx.body = {
+ success: true,
+ message: "头像上传成功",
+ url,
+ thumb: result.thumb,
+ user: updatedUser,
+ }
+ } catch (error) {
+ logger.error(`上传头像失败: ${error.message}`)
+ ctx.status = 500
+ ctx.body = { success: false, message: error.message || "上传头像失败" }
+ }
+ }
+
+ /**
+ * 创建用户资料相关路由
+ * @returns {Router} 路由实例
+ */
+ static createRoutes() {
+ const controller = new ProfileController()
+ const router = new Router({ auth: "try" })
+
+ // 用户资料页面
+ router.get("/profile", controller.profileGet.bind(controller), { auth: true })
+
+ // 用户资料更新
+ router.post("/profile/update", controller.profileUpdate.bind(controller), { auth: true })
+
+ // 密码修改
+ router.post("/profile/change-password", controller.changePassword.bind(controller), { auth: true })
+
+ // 头像上传
+ router.post("/profile/upload-avatar", controller.uploadAvatar.bind(controller), { auth: true })
+
+ return router
+ }
+}
+
+export default ProfileController
\ No newline at end of file
diff --git a/src/controllers/Page/UploadController.js b/src/controllers/Page/UploadController.js
new file mode 100644
index 0000000..e172b7f
--- /dev/null
+++ b/src/controllers/Page/UploadController.js
@@ -0,0 +1,200 @@
+import Router from "utils/router.js"
+import formidable from "formidable"
+import fs from "fs/promises"
+import path from "path"
+import { fileURLToPath } from "url"
+import { logger } from "@/logger.js"
+import { R } from "@/utils/helper"
+
+/**
+ * 文件上传控制器
+ * 负责处理通用文件上传功能
+ */
+class UploadController {
+ constructor() {
+ // 初始化上传配置
+ this.initConfig()
+ }
+
+ /**
+ * 初始化上传配置
+ */
+ initConfig() {
+ // 默认支持的文件类型配置
+ this.defaultTypeList = [
+ { mime: "image/jpeg", ext: ".jpg" },
+ { mime: "image/png", ext: ".png" },
+ { mime: "image/webp", ext: ".webp" },
+ { mime: "image/gif", ext: ".gif" },
+ { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx
+ { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls
+ { mime: "application/msword", ext: ".doc" }, // .doc
+ { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx
+ ]
+
+ this.fallbackExt = ".bin"
+ this.maxFileSize = 10 * 1024 * 1024 // 10MB
+ }
+
+ /**
+ * 获取允许的文件类型
+ * @param {Object} ctx - Koa上下文
+ * @returns {Array} 允许的文件类型列表
+ */
+ getAllowedTypes(ctx) {
+ let typeList = this.defaultTypeList
+
+ // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型)
+ if (ctx.query.allowedTypes) {
+ const allowed = ctx.query.allowedTypes
+ .split(",")
+ .map(t => t.trim())
+ .filter(Boolean)
+ typeList = this.defaultTypeList.filter(item => allowed.includes(item.mime))
+ }
+
+ return typeList
+ }
+
+ /**
+ * 获取上传目录路径
+ * @returns {string} 上传目录路径
+ */
+ getUploadDir() {
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
+ const publicDir = path.resolve(__dirname, "../../../public")
+ return path.resolve(publicDir, "uploads/files")
+ }
+
+ /**
+ * 确保上传目录存在
+ * @param {string} dir - 目录路径
+ */
+ async ensureUploadDir(dir) {
+ await fs.mkdir(dir, { recursive: true })
+ }
+
+ /**
+ * 生成安全的文件名
+ * @param {Object} ctx - Koa上下文
+ * @param {string} ext - 文件扩展名
+ * @returns {string} 生成的文件名
+ */
+ generateFileName(ctx, ext) {
+ return `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`
+ }
+
+ /**
+ * 获取文件扩展名
+ * @param {Object} file - 文件对象
+ * @param {Array} typeList - 类型列表
+ * @returns {string} 文件扩展名
+ */
+ getFileExtension(file, typeList) {
+ // 优先用mimetype判断扩展名
+ let ext = (typeList.find(item => item.mime === file.mimetype) || {}).ext
+ if (!ext) {
+ // 回退到原始文件名的扩展名
+ ext = path.extname(file.originalFilename || file.newFilename || "") || this.fallbackExt
+ }
+ return ext
+ }
+
+ /**
+ * 处理单个文件上传
+ * @param {Object} file - 文件对象
+ * @param {Object} ctx - Koa上下文
+ * @param {string} uploadsDir - 上传目录
+ * @param {Array} typeList - 类型列表
+ * @returns {string} 文件URL
+ */
+ async processFile(file, ctx, uploadsDir, typeList) {
+ if (!file) return null
+
+ const oldPath = file.filepath || file.path
+ const ext = this.getFileExtension(file, typeList)
+ const filename = this.generateFileName(ctx, ext)
+ const destPath = path.join(uploadsDir, filename)
+
+ // 移动文件到目标位置
+ if (oldPath && oldPath !== destPath) {
+ await fs.rename(oldPath, destPath)
+ }
+
+ // 返回相对于public的URL路径
+ return `/uploads/files/${filename}`
+ }
+
+ // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组)
+ async upload(ctx) {
+ try {
+ const uploadsDir = this.getUploadDir()
+ await this.ensureUploadDir(uploadsDir)
+
+ const typeList = this.getAllowedTypes(ctx)
+ const allowedTypes = typeList.map(item => item.mime)
+
+ const form = formidable({
+ multiples: true, // 支持多文件
+ maxFileSize: this.maxFileSize,
+ filter: ({ mimetype }) => {
+ return !!mimetype && allowedTypes.includes(mimetype)
+ },
+ uploadDir: uploadsDir,
+ keepExtensions: true,
+ })
+
+ const { files } = await new Promise((resolve, reject) => {
+ form.parse(ctx.req, (err, fields, files) => {
+ if (err) return reject(err)
+ resolve({ fields, files })
+ })
+ })
+
+ let fileList = files.file
+ if (!fileList) {
+ return R.ResponseJSON(R.ERROR, null, "未选择文件或字段名应为 file")
+ }
+
+ // 统一为数组
+ if (!Array.isArray(fileList)) {
+ fileList = [fileList]
+ }
+
+ // 处理所有文件
+ const urls = []
+ for (const file of fileList) {
+ const url = await this.processFile(file, ctx, uploadsDir, typeList)
+ if (url) {
+ urls.push(url)
+ }
+ }
+
+ ctx.body = {
+ success: true,
+ message: "上传成功",
+ urls,
+ }
+ } catch (error) {
+ logger.error(`上传失败: ${error.message}`)
+ ctx.status = 500
+ ctx.body = { success: false, message: error.message || "上传失败" }
+ }
+ }
+
+ /**
+ * 创建文件上传相关路由
+ * @returns {Router} 路由实例
+ */
+ static createRoutes() {
+ const controller = new UploadController()
+ const router = new Router({ auth: "try" })
+
+ // 通用文件上传
+ router.post("/upload", controller.upload.bind(controller), { auth: true })
+
+ return router
+ }
+}
+
+export default UploadController
\ No newline at end of file
diff --git a/src/controllers/Page/HtmxController.js b/src/controllers/Page/_Demo/HtmxController.js
similarity index 100%
rename from src/controllers/Page/HtmxController.js
rename to src/controllers/Page/_Demo/HtmxController.js
diff --git a/src/utils/ForRegister.js b/src/utils/ForRegister.js
index f21bcf3..39b1b70 100644
--- a/src/utils/ForRegister.js
+++ b/src/utils/ForRegister.js
@@ -30,8 +30,10 @@ export function autoRegisterControllers(app, controllersDir) {
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
- scan(fullPath, routePrefix + "/" + file)
- } else if (file.endsWith("Controller.js")) {
+ if (!file.startsWith("_")) {
+ scan(fullPath, routePrefix + "/" + file)
+ }
+ } else if (file.endsWith("Controller.js") && !file.startsWith("_")) {
try {
// 使用同步的import方式,确保ES模块兼容性
const controllerModule = require(fullPath)