Browse Source

refactor(controllers): 拆分页面控制器为多个单一职责控制器

- 将原PageController拆分为AuthPageController、BasePageController、ProfileController、UploadController,职责更加单一明确
- AuthPageController负责认证相关功能:登录、注册、验证码、登出等
- BasePageController负责基础页面功能:首页、静态页面和联系表单
- ProfileController负责用户资料管理、密码修改和头像上传
- UploadController负责通用文件上传功能
- 移除原PageController相关代码,优化代码结构和维护性
- 保留各控制器对应的路由创建方法,保持接口兼容性
re
谢亚昕 3 months ago
parent
commit
303671102b
  1. BIN
      database/development.sqlite3-shm
  2. BIN
      database/development.sqlite3-wal
  3. 136
      src/controllers/Page/AuthPageController.js
  4. 102
      src/controllers/Page/BasePageController.js
  5. 481
      src/controllers/Page/PageController.js
  6. 228
      src/controllers/Page/ProfileController.js
  7. 200
      src/controllers/Page/UploadController.js
  8. 0
      src/controllers/Page/_Demo/HtmxController.js
  9. 4
      src/utils/ForRegister.js

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

136
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

102
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: "随机图片,点击查看。<br> 右键可复制链接",
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

481
src/controllers/Page/PageController.js

@ -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: "随机图片,点击查看。<br> 右键可复制链接",
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

228
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

200
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

0
src/controllers/Page/HtmxController.js → src/controllers/Page/_Demo/HtmxController.js

4
src/utils/ForRegister.js

@ -30,8 +30,10 @@ export function autoRegisterControllers(app, controllersDir) {
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
if (!file.startsWith("_")) {
scan(fullPath, routePrefix + "/" + file)
} else if (file.endsWith("Controller.js")) {
}
} else if (file.endsWith("Controller.js") && !file.startsWith("_")) {
try {
// 使用同步的import方式,确保ES模块兼容性
const controllerModule = require(fullPath)

Loading…
Cancel
Save