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